CSS, layout & accessibility

Accessibility fundamentals

WCAG 2.2 and its new criteria, the European Accessibility Act, semantic HTML first, the rules of ARIA, keyboard and focus management, forms and errors, colour and motion, screen reader testing, and the tooling that makes accessibility verifiable.

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.

Know

  • WCAG 2.2 (W3C Recommendation, October 2023) organises criteria under POUR: Perceivable, Operable, Understandable, Robust, at levels A, AA (the usual contractual target) and AAA. New in 2.2: Focus Not Obscured (2.4.11 AA), Focus Appearance (2.4.13 AAA), Dragging Movements (2.5.7 AA: a non-drag alternative), Target Size Minimum (2.5.8 AA: 24×24 CSS px or spacing), Consistent Help (3.2.6 A), Redundant Entry (3.3.7 A: do not ask twice), Accessible Authentication (3.3.8 AA: no cognitive tests such as transcribing codes without alternatives); 4.1.1 Parsing was removed.
  • The European Accessibility Act (Directive 2019/882) applies from 28 June 2025 to e-commerce, banking, e-books, transport and other consumer services, referencing EN 301 549, which maps to WCAG 2.1 AA (2.2 expected to follow). The EU Web Accessibility Directive already covered public bodies. Say "legal requirement with WCAG AA as the technical bar".
  • Semantic HTML first. <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.
  • The rules of ARIA: do not use ARIA if a native element does the job; do not change native semantics unless you must; every interactive ARIA control must be keyboard operable; do not 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.
  • Keyboard and focus: everything reachable and operable with Tab, Shift+Tab, Enter, Space, arrows and Escape; visible focus indicators (: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.
  • Forms: every control has a label (<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.
  • Colour, contrast, motion: text contrast 4.5:1 (AA, 3:1 for large text), 3:1 for UI components and graphics; never colour alone to convey meaning; respect prefers-reduced-motion; no flashing over 3 Hz; forced-colors support.
  • Content and media: meaningful 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.
  • Testing: automated (axe DevTools, Lighthouse, @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.

How it works

Semantic structure and the patterns that carry most of WCAG for free:

app/layouts/default.vue
<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:

app/components/Checkout/Address.vue
<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:

test/e2e/a11y.spec.ts
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()
})
Gotcha· outline: none

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.

Gotcha· Automated tools say 0 issues

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.

Verify before the interview:

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 ↗

Exercise

Exercise
  • Navigate your app with the keyboard only for five minutes; write down every place focus disappears, is trapped, or lacks a visible ring.
  • Turn on VoiceOver (⌘F5) and complete a form in your app; note what is announced for each field and each error.
  • Add the axe Playwright test to a project and fix the violations until it passes at wcag22aa.

Be able to say

Be able to say· What does WCAG 2.2 require, and how do you make sure a product meets it?

"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."

Be able to say· What are the rules for using ARIA?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.