> Component-level WCAG 2.2 patterns for Vue and Nuxt: focus in modals and Teleport, live regions, comboboxes, what axe-core misses, and a test ladder.
>
> Web page: https://balazscsorba.com/blog/vue-accessible-components · Language: English · Also available in: [Deutsch](https://balazscsorba.com/de/blog/vue-accessible-components.md) · [Magyar](https://balazscsorba.com/hu/blog/vue-accessible-components.md)
> Author: Balázs Csorba · Published: 2026-10-02 · Keywords: accessible vue components, vue accessibility wcag 2.2, vue focus management modal, aria-live vue async, reka ui accessibility, eslint-plugin-vuejs-accessibility, axe-core playwright vue, vue combobox accessibility, wcag target size 24px, spa route change announcement accessibility

[Blog](https://balazscsorba.com/blog)/Web engineering

# Accessible Vue components: the WCAG 2.2 patterns automated tests miss

Component-level WCAG 2.2 patterns for Vue and Nuxt: focus in modals and Teleport, live regions, comboboxes, what axe-core misses, and a test ladder.

[Balázs Csorba](https://balazscsorba.com/about)·October 2, 2026·12 min read

-   Vue accessibility
-   WCAG 2.2
-   Focus management
-   Live regions
-   Component testing

![A dialog with a focus ring on one button, a tab key and a speaker beside it.](https://balazscsorba.com/images/blog/vue-accessible-components/cover.webp?v=39acedf83b)

## Key takeaways

-   Five of the nine criteria that WCAG 2.2 adds land directly in component code: focus not obscured, dragging movements, target size, redundant entry and accessible authentication.
-   axe-core finds on average 57% of WCAG issues automatically, and its only WCAG 2.2 rule is disabled by default, so a green scan is not evidence of keyboard or screen-reader quality.
-   Focus management belongs in the component: a native dialog with an inert background is the cheaper baseline, and single-page route changes need an explicit focus move.
-   Live regions must exist before they change and carry plain text, and streamed AI output should be announced in sentence-sized pieces rather than token by token.
-   Test in a ladder, from lint and component axe scans to ARIA snapshots and a manual screen-reader pass, and write the definition of done down so that review can enforce it.

On this page

1.  [Which WCAG 2.2 criteria land in component code?](https://balazscsorba.com/#wcag-criteria-in-component-code)
2.  [What does axe-core actually cover?](https://balazscsorba.com/#what-axe-actually-covers)
3.  [How should focus behave in modals, Teleport and route changes?](https://balazscsorba.com/#focus-management-modals-teleport-and-route-changes)
4.  [How do async updates and live regions behave?](https://balazscsorba.com/#async-ui-and-live-regions)
5.  [Should you build a custom select or combobox?](https://balazscsorba.com/#custom-selects-comboboxes-and-when-not-to-build-one)
6.  [What do headless libraries still leave to you?](https://balazscsorba.com/#headless-libraries-what-they-still-dont-own)
7.  [How do you test all of this?](https://balazscsorba.com/#the-testing-ladder)
8.  [What belongs in a component definition of done?](https://balazscsorba.com/#component-definition-of-done-checklist)
9.  [Sources](https://balazscsorba.com/#sources)

Listen to this article

0:000:00

**Most accessibility defects in a Vue app are component defects.** A dialog that does not return focus, a toast that a screen reader never announces, a combobox that ignores the arrow keys, an icon button 16 pixels square: each one is written once in a shared component and then ships on every page that uses it. That makes components the right unit for accessibility work and the right place to write the rules down.

This article is the engineering half of WCAG 2.2 for Vue and Nuxt. It maps the success criteria that land in component code, explains what axe-core can and cannot see, walks through focus management, async live regions and custom comboboxes, and ends with a testing ladder and a definition-of-done checklist you can paste into a pull request template. It does not decide whether a site is compliant; for the legal framing, see [the European Accessibility Act for B2B shops](https://balazscsorba.com/blog/european-accessibility-act-b2b-shop).

## Which WCAG 2.2 criteria land in component code?

Five of the nine criteria that [WCAG 2.2](https://www.w3.org/TR/WCAG22/) marks as new land in component code directly: 2.4.11 Focus Not Obscured, 2.5.7 Dragging Movements, 2.5.8 Target Size (Minimum), 3.3.7 Redundant Entry and 3.3.8 Accessible Authentication (Minimum). The older criteria that fail most often are still 2.4.7 Focus Visible and the contrast criteria 1.4.3 and 1.4.11. Those did not change in 2.2, but they sit in every custom skin.

Each one maps to a specific Vue artifact. Focus Not Obscured usually means a sticky header, a cookie bar or a floating chat widget covering the focused row or button. Target Size applies to icon-only close buttons and toolbars whose hit areas are smaller than 24 by 24 CSS pixels. Dragging Movements applies to sortable lists and sliders, which need a single-pointer alternative such as buttons. Redundant Entry hits multi-step forms that ask again for an address entered two steps earlier. Accessible Authentication hits login screens and one-time-code fields that block paste or password managers.

The table is the working map: one row per criterion, the Vue artifact where it shows up, whether axe-core has a rule for it today, and the cheapest fix.

Criterion

Vue artifact

axe rule today

Cheapest fix

2.4.7 Focus Visible (AA)

focus styles on custom controls

none

keep the native outline; replace it, never just remove it

2.4.11 Focus Not Obscured (AA)

sticky header, cookie bar, chat widget

none

scroll-margin on focus targets

2.5.7 Dragging Movements (AA)

sortable lists, sliders

none

buttons that move the items

2.5.8 Target Size (AA)

icon-only close and toolbar buttons

target-size, off by default

a 24 by 24 px hit area

3.3.7 Redundant Entry (A)

multi-step checkout and address forms

none

prefill from earlier steps

3.3.8 Accessible Authentication (AA)

login, OTP and password fields

none

allow paste and password managers

1.4.3 and 1.4.11 Contrast

design tokens, borders, focus rings

text only (color-contrast)

check every token pair in every state

4.1.2 Name, Role, Value

custom comboboxes, icon buttons

partial

use the native element first

## What does axe-core actually cover?

Automated scanners find a useful share of WCAG problems, but not the ones that usually break a keyboard or screen-reader user. axe-core states that on average it finds 57% of WCAG issues automatically. Its WCAG 2.2 section contains one rule, target-size, and that rule is disabled by default.

The [axe-core README](https://github.com/dequelabs/axe-core) is explicit about the ceiling: “With axe-core, you can find on average 57% of WCAG issues automatically.” The [rule descriptions](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md) confirm the WCAG 2.2 gap: the only 2.2 rule is target-size, disabled until WCAG 2.2 is more widely adopted. No rule checks focus not obscured, redundant entry or accessible authentication.

The population data says the gap is measurable. [WebAIM’s Million 2026](https://webaim.org/projects/million/) crawl of one million home pages found 56.1 detected errors per page on average, up 10.1% from 51 in 2025. Home pages built with Vue.js averaged 64.6 detected errors, and Nuxt.js pages 54.7. WebAIM states plainly that not all conformance failures can be detected automatically, so read these figures as what the tooling sees, not as a verdict on any site.

**A green scan is not a pass**

Treat an automated scan as an alarm for the defects it can name. Keyboard order, focus behaviour and screen-reader announcements need a person, and the testing ladder later in this article assigns each defect class to the rung that can catch it.

## How should focus behave in modals, Teleport and route changes?

A modal must move focus in when it opens, keep Tab and Shift+Tab inside it, close on Escape, and return focus to the element that opened it. A hand-rolled focus trap can do all four, but a native dialog element, with the rest of the page marked inert, does most of the work with less code.

Hand-rolled traps usually fail in three places. They collect focusable elements once at mount time, so a field rendered later is skipped. They scope to the container the component was written in, but Teleport moves the dialog’s DOM node to another part of the document, so the trap misses it. And they restore focus to a button that a re-render has already removed, so focus falls back to the body.

The cheaper baseline is the native dialog. Calling `showModal()` places the element in the top layer, makes the rest of the document inert, and closes on Escape. Current engines also return focus to the opener when the dialog closes, but restoration details have differed between implementations, so write a test for it in each browser you ship to. Where a native dialog does not fit, the `inert` attribute gives you the same effect on the page behind a custom overlay.

![Focus lifecycle of a Teleported dialog Five steps from left to right: the trigger opens the dialog and focus moves to its first control; Tab and Shift+Tab stay inside the dialog; Escape or the close button closes it; focus then returns to the element that opened it.](https://balazscsorba.com/images/blog/vue-accessible-components/diagram-16.webp?v=004deddda0)

Focus lifecycle of a Teleported dialog: open from the trigger, move focus inside, keep Tab inside, close with Escape or the close button, and return focus to the trigger.

### Route changes in Vue Router and Nuxt

Single-page navigation does not reload the document, so the browser neither moves focus nor announces the new page. [Vue’s accessibility guide](https://vuejs.org/guide/best-practices/accessibility.html) covers this with a skip link as the first focusable element and a watcher on `route.path` that moves focus after each route change. The sentinel pattern used here places a focusable heading at the top of the main region, and the layout focuses it after navigation. Focusing a heading or sentinel works better than focusing `body`, because `body` is not a useful place for a screen reader to start reading.

```
<!-- Layout sketch: move focus to the page heading after each route change -->
<script setup lang="ts">
const route = useRoute()
const sentinel = ref<HTMLElement | null>(null)

watch(() => route.path, async () => {
  await nextTick() // let the new page render first
  sentinel.value?.focus()
})
</script>

<template>
  <main>
    <h1 ref="sentinel" tabindex="-1"><slot name="title" /></h1>
    <slot />
  </main>
</template>
```

Pair the focus move with an announcement only where the focused heading does not already tell the user where they are. Whether a changed document title alone is announced depends on the screen reader and the browser, so test that combination instead of assuming it.

## How do async updates and live regions behave?

A live region announces a change only if the region existed before the change. Render the container with the page, then update its text. Use polite by default, assertive only for errors that block the user, and never put controls inside the region, because live regions are announced as plain text.

The [MDN guidance on live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions) comes down to the same rules: establish the region before updating it, defer the first content update to a later task, and reserve assertive for urgent messages. It also warns that live regions are typically announced as plain text, so links, buttons and other semantics in the updated content may not be conveyed.

This is the mistake that breaks error toasts. If a component mounts a toast together with its live region, the region and the message arrive in the same DOM update, and many screen readers never announce the message. The fix is a region rendered with the page, present but empty, into which the component writes the toast text. The same applies to inline validation summaries and to save confirmations after a route change.

Streamed output is the hardest case. If every chunk of an LLM answer goes into a polite region, a screen reader reads the answer word by word and keeps interrupting itself. Keep the visible text updating as it streams, and announce a summary or the completed answer at sentence or completion boundaries. The [streaming chat accessibility post](https://balazscsorba.com/blog/vue-ai-chat-ui-streaming-accessibility) covers the chat-specific details.

## Should you build a custom select or combobox?

Use a native select whenever the design allows it. Build a custom combobox only when the product needs something a native control cannot do, and then own the full keyboard contract from the [WAI-ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/practices/).

The APG states the cost plainly: “Unlike native HTML form elements, browsers do not provide keyboard support for graphical user interface (GUI) components that are made accessible with ARIA; authors have to provide the keyboard support in their code.” For a combobox, that means arrow keys move through the options, Enter selects, Escape closes, typing filters the list, and focus stays where the pattern says it stays. The APG treats combobox, listbox, dialog, grid, carousel and accordion as separate patterns with separate contracts, and teams often mix them up.

Two implementation choices matter. `aria-activedescendant` keeps DOM focus on the text input and points at the highlighted option by its id, which suits filterable comboboxes. A roving tabindex moves real focus between items, which suits toolbars, menus and grids. Choose by what the user types into: a text field argues for activedescendant, a row of controls for a roving tabindex.

A custom control is permanent maintenance. Every framework upgrade, every new option type and every design change can break the contract, and the component test has to exercise the keyboard, not only the rendered markup. If a native select with a styled wrapper meets the requirement, that is almost always the cheaper component.

## What do headless libraries still leave to you?

Headless component libraries supply the ARIA wiring, keyboard map and focus scope of a pattern. They do not supply your accessible names, your colour contrast, your error messages or the target size of your icon buttons. [Reka UI](https://reka-ui.com/docs/overview/introduction) is one of the Vue options as of October 2026, and its documentation is explicit about what it covers.

Reka UI presents itself as the v2 evolution of Radix Vue and says its components align with WAI-ARIA design patterns, handling details such as aria attributes, keyboard navigation and focus management. The rest of the contract stays with the team that owns the product. The list below is what remains yours in any headless setup:

-   **Accessible names:** an icon button that closes the cart needs a name such as “Close cart”. A library cannot infer what a bare icon means.
-   **Contrast in your skin:** the library ships no colours, so every token pair needs checking in light, dark and every state.
-   **Error messaging:** the error text has to be connected to its field with `aria-describedby`, and it has to say what to do next.
-   **Route announcements:** page changes in a single-page app are yours, as described above.
-   **Target size:** a headless button is still the size your CSS makes it.

## How do you test all of this?

Test in layers, cheapest first: lint the templates, scan each component in unit tests, scan real flows end to end, assert the accessibility tree with ARIA snapshots, and finish with a manual pass using a keyboard and a screen reader. Each rung catches defect classes that the rungs before it cannot, and the top rung still needs a person.

![The testing ladder Five rungs from left to right: template lint rules, a component-level axe scan in unit tests, an axe scan of real flows end to end, an ARIA snapshot that asserts the accessibility tree, and a manual pass with a keyboard and a screen reader.](https://balazscsorba.com/images/blog/vue-accessible-components/diagram-37.webp?v=4c6e41c627)

The testing ladder, cheapest first: each rung catches defect classes that the rungs before it cannot, and the top rung still needs a person with a screen reader.

Lint comes first because the cheapest defects are the ones a template makes visible. eslint-plugin-vuejs-accessibility flags common template mistakes such as missing alternative text and invalid ARIA attributes. It cannot tell whether an alternative text is meaningful, so it works as a filter, not a verdict.

Unit tests run axe-core against each component in isolation, which keeps a failure pointed at one component.

```
// Vitest: scan the rendered dialog, not the whole page
import axe from 'axe-core'

it('confirm dialog has no detectable violations', async () => {
  const { container } = render(ConfirmDialog, { props: { open: true } })
  const results = await axe.run(container)
  expect(results.violations).toEqual([])
})
```

The end-to-end rung runs `@axe-core/playwright` against real flows: open the dialog, submit the form with an error, then scan the state the user actually reaches. The [Playwright accessibility guide](https://playwright.dev/docs/accessibility-testing) recommends this combination and says plainly that many problems can only be discovered through manual testing. Playwright also has `toMatchAriaSnapshot`, added in version 1.49, which [asserts the accessibility tree](https://playwright.dev/docs/api/class-locatorassertions), so a button that silently loses its name fails a test that a visual snapshot would miss.

```
// Playwright: scan the state a user reaches, not the empty page
import AxeBuilder from '@axe-core/playwright'

await page.getByRole('button', { name: 'Delete order' }).click()
const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze()
expect(results.violations).toEqual([])
```

The top rung is a person with a screen reader. [WebAIM’s Screen Reader User Survey #11](https://webaim.org/projects/screenreadersurvey11/), run in July and August 2026 with 1,780 responses, names JAWS as the primary desktop screen reader for 55.0% of respondents, NVDA for 32.9% and VoiceOver for 6.5%. Test with NVDA on Windows and VoiceOver on macOS and iOS as a baseline, and add JAWS when your users use it. Work through the whole flow with the keyboard alone first, because most focus bugs show up before any screen reader is running. I run this second pass from an agent session with the Playwright MCP server, so the agent can open the dialog, tab through it and run the scan, but a person still listens to the announcements.

For development, [@nuxt/a11y](https://github.com/nuxt/a11y) runs axe-core inside Nuxt DevTools with route-aware scanning and can produce reports during `nuxt generate`. Its latest release is still 1.0.0-alpha.1, so use it as a development aid and keep the CI gate in the rungs above. The same discipline applies as in [Nuxt Core Web Vitals and performance budgets](https://balazscsorba.com/blog/nuxt-core-web-vitals-performance): a number checked in CI stays true, while a number checked once does not.

## What belongs in a component definition of done?

A component is done when its name, role, keyboard behaviour, focus handling, contrast, target size and live-region behaviour have each been verified and recorded in review. The list below is written so it can go into a pull request template or a review skill, and each item maps to one rung of the testing ladder.

-   **Name and role:** every control has an accessible name, and its role matches a native element or the APG pattern it implements.
-   **Keyboard:** every action works with the keys the pattern defines, and no focus trap exists outside dialogs.
-   **Focus:** the focus indicator is visible in light and dark themes, is not hidden by sticky UI at its scroll position, and returns to the trigger after close or navigation.
-   **Contrast:** text meets 4.5:1 and required visual states meet 3:1, in light and dark themes.
-   **Target size:** pointer targets are at least 24 by 24 CSS px, or spaced far enough apart to meet the exception.
-   **Live regions:** the region exists before the update, is polite unless the message blocks the user, and contains plain text only.
-   **Forms:** errors are tied to their fields and do not rely on colour alone, redundant entries are prefilled, and authentication fields accept paste and password managers.
-   **Dragging:** every drag action has a single-pointer alternative.
-   **Motion:** transitions that carry meaning respect `prefers-reduced-motion`.
-   **Right-to-left:** the focus indicator and icon direction work in right-to-left layouts.

Once the list is written down, a reviewer agent can check new components against it before a human reads the diff, while the screen-reader pass stays with a person. I keep about 20 agent skills in `~/.agents/skills`, and this checklist is a natural candidate for one. If you are building or auditing a Vue or Nuxt component library and want this wired into CI, see my [Vue and Nuxt work](https://balazscsorba.com/expertise/vue-nuxt-developer).

## Sources

1.  [W3C: Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/)
2.  [axe-core README (Deque)](https://github.com/dequelabs/axe-core)
3.  [axe-core rule descriptions](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)
4.  [WebAIM: The WebAIM Million 2026](https://webaim.org/projects/million/)
5.  [WebAIM: Screen Reader User Survey #11](https://webaim.org/projects/screenreadersurvey11/)
6.  [Vue: accessibility guide](https://vuejs.org/guide/best-practices/accessibility.html)
7.  [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/practices/)
8.  [MDN: ARIA live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)
9.  [Playwright: accessibility testing](https://playwright.dev/docs/accessibility-testing)
10.  [Playwright: locator assertions (toMatchAriaSnapshot)](https://playwright.dev/docs/api/class-locatorassertions)
11.  [nuxt/a11y on GitHub](https://github.com/nuxt/a11y)
12.  [Reka UI: introduction](https://reka-ui.com/docs/overview/introduction)

## Frequently asked questions

Does axe-core make a Vue app accessible?

No. axe-core says it finds on average 57% of WCAG issues automatically, and its WCAG 2.2 rule is disabled by default. Keyboard, focus and screen-reader behaviour still need manual testing.

Which WCAG 2.2 criteria affect Vue components most?

Focus Not Obscured, Dragging Movements, Target Size (Minimum), Redundant Entry and Accessible Authentication (Minimum) land directly in component code. Focus Visible and the contrast criteria are older, but they fail just as often in custom styling.

How do you handle focus in a modal that uses Teleport?

Use a native dialog opened with showModal(), mark the background inert where needed, and test focus return after close. A hand-rolled trap that searches inside its original parent misses the teleported dialog.

Why are my screen reader announcements not read out?

Often the live region was created in the same update as its message. Render the region with the page first, keep it present but empty, and write the message into it afterwards.

Should I use a custom combobox or a native select?

Use a native select unless the product needs behaviour it cannot provide. A custom combobox means owning the APG keyboard contract and keeping it working through every upgrade.

Written by Balázs Csorba

Senior fullstack & AI engineer in Styria, Austria – 10+ years of Vue, Nuxt, Node.js and PHP, now building tooling for AI agents.

[Vue.js & Nuxt development →](https://balazscsorba.com/expertise/vue-nuxt-developer)[About me →](https://balazscsorba.com/about)

## More articles

-   [Schema.org structured data for B2B shops: prices behind logins, units and variants](https://balazscsorba.com/blog/schema-org-structured-data-b2b-shop)
-   [An AI assistant for B2B shop support: tools, permissions and the human handoff](https://balazscsorba.com/blog/ai-assistant-b2b-shop-customer-support)
-   [Streaming AI chat in Vue and Nuxt: accessible by design](https://balazscsorba.com/blog/vue-ai-chat-ui-streaming-accessibility)
-   [An LLM translation pipeline for i18n: glossary, placeholders and review](https://balazscsorba.com/blog/llm-translation-pipeline-i18n)

## Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.

[Get in touch](mailto:contact@balazscsorba.com) [Connect on LinkedIn](https://www.linkedin.com/in/balazs-csorba)
