Accessibility moved from "nice to have" to "line item in the contract" for most European products when the European Accessibility Act started applying in June 2025. Interviewers now ask about WCAG criteria by number, about how you test with a screen reader, and about the trade-offs in building versus adopting accessible components. This page is the fundamentals; the next one is the Vue practice.
<button> for actions, <a href> for navigation, <nav>, <main>, <header>, <footer>, <aside>, <section> with headings, <ul>/<ol>, <table> with <th scope>, <dialog>, <details>, <fieldset>/<legend>. Native elements bring keyboard behaviour, focusability and semantics for free; a <div @click> brings none.aria-hidden focusable elements; every interactive element needs an accessible name. Key attributes: aria-label/aria-labelledby, aria-describedby, aria-expanded, aria-controls, aria-selected, aria-current, aria-live/role="status"/role="alert", aria-invalid, aria-busy, aria-hidden.:focus-visible, not outline: none); logical tab order following DOM order; focus management on route change and in dialogs (trap inside, restore on close, inert on the rest or <dialog showModal()>); skip links; roving tabindex for composite widgets (tabs, menus, grids) so one Tab stop enters the widget and arrows move inside.<label for> or aria-labelledby), instructions and errors are associated (aria-describedby), errors are announced (role="alert" or a live region) and identified in text, not colour alone; autocomplete attributes; no time limits without extension; Redundant Entry and Accessible Authentication apply to checkout and login.prefers-reduced-motion; no flashing over 3 Hz; forced-colors support.alt (empty for decorative), captions and transcripts, headings that describe structure, link text that makes sense out of context, lang on <html> and on foreign phrases, page titles per route.@axe-core/playwright or vitest-axe in CI, eslint-plugin-vuejs-accessibility) catches roughly 30–40% of issues; manual: keyboard-only pass, screen reader pass (VoiceOver on macOS/iOS, NVDA on Windows, TalkBack on Android), 200% zoom, reduced motion, high contrast; process: acceptance criteria per component, an accessibility checklist in the definition of done, and user testing with disabled users for critical flows.Semantic structure and the patterns that carry most of WCAG for free:
<template>
<a href="#main" class="skip-link">Skip to content</a>
<header>
<nav aria-label="Primary"><slot name="nav" /></nav>
</header>
<main id="main" tabindex="-1">
<slot />
</main>
<footer><slot name="footer" /></footer>
<NuxtRouteAnnouncer />
<NuxtAnnouncer />
</template>
<style>
.skip-link { position: absolute; inset-block-start: -100%; &:focus { inset-block-start: 0.5rem; inset-inline-start: 0.5rem; } }
:focus-visible { outline: 3px solid var(--ui-primary); outline-offset: 2px; }
</style>
A form that meets the form-related criteria:
<script setup lang="ts">
const id = useId()
const errors = reactive<{ postcode?: string }>({})
const { assertive } = useAnnouncer()
async function submit() {
errors.postcode = validatePostcode(form.postcode) ? undefined : 'Enter a valid postcode, for example 110 00'
if (errors.postcode) {
assertive('The form has 1 error') // announced immediately
document.getElementById(`${id}-postcode`)?.focus() // move focus to the first invalid field
}
}
</script>
<template>
<form novalidate @submit.prevent="submit">
<fieldset>
<legend>Delivery address</legend>
<label :for="`${id}-postcode`">Postcode <span aria-hidden="true">*</span></label>
<input
:id="`${id}-postcode`"
v-model="form.postcode"
autocomplete="postal-code"
inputmode="numeric"
required
:aria-invalid="!!errors.postcode || undefined"
:aria-describedby="errors.postcode ? `${id}-postcode-error` : `${id}-postcode-hint`"
>
<p :id="`${id}-postcode-hint`">Five digits, with or without a space.</p>
<p v-if="errors.postcode" :id="`${id}-postcode-error`" role="alert">{{ errors.postcode }}</p>
</fieldset>
<button type="submit" class="min-h-11 min-w-11">Continue</button>
</form>
</template>
Automated checks in the pipeline, so regressions are caught:
import { expect, test } from '@nuxt/test-utils/playwright'
import AxeBuilder from '@axe-core/playwright'
test('checkout has no critical accessibility violations', async ({ page, goto }) => {
await goto('/checkout', { waitUntil: 'hydration' })
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze()
expect(results.violations.filter(v => v.impact === 'critical' || v.impact === 'serious')).toEqual([])
})
test('checkout is keyboard operable', async ({ page, goto }) => {
await goto('/checkout', { waitUntil: 'hydration' })
await page.keyboard.press('Tab') // skip link
await page.keyboard.press('Enter')
await expect(page.locator('#main')).toBeFocused()
await page.getByRole('button', { name: 'Continue' }).focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('alert')).toBeVisible()
})
Removing focus outlines for aesthetics fails 2.4.7 Focus Visible and strands keyboard users. Style :focus-visible instead of :focus so mouse users do not see the ring and keyboard users always do.
axe and Lighthouse cannot judge whether a label is meaningful, whether focus order makes sense, or whether an announcement is helpful; they catch under half of real barriers. A green axe run is the start of testing, not the end; a ten-minute screen reader pass finds the rest.
Whether EN 301 549 has been updated to reference WCAG 2.2 for the European Accessibility Act, and the status of WCAG 3.0 drafts, as of the interview week.
docs ↗wcag22aa."WCAG 2.2 groups criteria under perceivable, operable, understandable and robust, and AA is the level contracts and the European Accessibility Act effectively require. The 2.2 additions matter in practice: 24-pixel minimum target size, focus not obscured by sticky elements, a non-drag alternative for drag interactions, no redundant entry in multi-step forms, and accessible authentication without cognitive tests. I meet it by choosing semantic elements first, using ARIA only to fill gaps, managing focus deliberately in dialogs and on navigation, associating every label, hint and error with its field, and keeping contrast and motion within the limits. I verify with axe in CI at the wcag22aa tag, a keyboard-only pass and a screen reader pass on critical flows, because automated tools catch less than half of the barriers."
"First, do not use it if a native element already provides the semantics: a <button> needs no role=\"button\". Second, do not change native semantics unless you have to. Third, every interactive ARIA control must work with the keyboard, because ARIA only changes what assistive technology hears, not what the element does. Fourth, never hide focusable elements with aria-hidden. Fifth, every interactive element needs an accessible name. When ARIA is needed, for tabs, comboboxes, menus, live regions, I follow the Authoring Practices patterns or use a headless library that implements them, because a wrong ARIA attribute is worse than none."
Nothing yet. Notes filed here show up on this page and the section overview.
Responsive and adaptive design
Mobile-first with fluid type and spacing, container queries over media queries for components, viewport units and safe areas, touch targets and pointer queries, user preference queries for motion, contrast and colour scheme, images that do not shift layout, and print.
Accessible components in Vue and Nuxt
Building the widgets that go wrong most — modal dialogs, comboboxes, tabs, menus, toasts, forms — with Reka UI and Nuxt UI or by hand following the ARIA Authoring Practices, plus route announcements, focus on navigation, reduced motion, and testing with role-based locators.