Checking It
Three passes: keyboard only, a screen reader, and axe in Vitest and Playwright, plus what none of them can tell you.
const results = await axe.run(wrapper.element)Accessibility is checked in three passes. They find different things, and none of them replaces the others.
Keyboard only
Put the mouse down and Tab through the page. Everything clickable must be reachable, the focus ring must be visible at every stop, the order must match the visual order, Esc must close what opened, and Tab must not escape an open modal or get stuck anywhere else.
A screen reader
VoiceOver on macOS (Cmd+F5) or NVDA on Windows. Listen to the name, role and state of each control, then open the reader's heading and landmark lists. This pass catches the button announced as "button" and the error message nobody hears.
Automation
axe-core runs rules against a rendered DOM. axe.run(context) returns a promise for a results object, and results.violations is the array to assert on — empty is the pass. Run it per component in Vitest and per page in Playwright through @axe-core/playwright, after driving the page into the state you care about. A check against an unopened dialog checks nothing.
What automation cannot tell you
It finds missing names, invalid ARIA values, duplicate ids, unlabelled fields and contrast failures: a minority of real problems. It cannot judge whether a name is meaningful, whether focus went somewhere sensible, whether the reading order makes sense, or whether an animation makes someone ill. A clean axe run is the floor, not the goal.
The example is a filter bar written to survive all three passes: a labelled select, a labelled checkbox, and a fieldset that names the group.
Example
<script setup lang="ts">
import { ref, useId } from 'vue'
type Status = 'all' | 'open' | 'closed'
const emit = defineEmits<{
change: [filter: { status: Status; mineOnly: boolean }]
}>()
const status = ref<Status>('all')
const mineOnly = ref(false)
const statusId = useId()
const mineId = useId()
function apply() {
emit('change', { status: status.value, mineOnly: mineOnly.value })
}
</script>
<template>
<fieldset @change="apply">
<legend>Filter tickets</legend>
<label :for="statusId">Status</label>
<select :id="statusId" v-model="status">
<option value="all">All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
<input :id="mineId" v-model="mineOnly" type="checkbox" />
<label :for="mineId">Only my tickets</label>
</fieldset>
</template>
<style scoped>
fieldset {
border: 1px solid #767676;
}
select:focus-visible,
input:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>When to use it
- Adding one axe assertion per component test so a missing label fails CI instead of review.
- Auditing a page in its loaded, filtered and error states rather than only on first paint.
- Proving by test that Esc closes a dialog and focus returns to the trigger.
- Running a ten-minute keyboard-only pass over the main flows before a release.
More examples
An axe assertion in a Vitest component test
Mapping to rule ids before asserting is what makes a failure readable; asserting on the raw violations array prints several screens of nodes. attachTo: document.body is required because Vue Test Utils mounts detached by default and axe refuses nodes outside the document, and the unmount() afterwards keeps one test's markup out of the next one's. Add this test once per component that renders controls and it becomes a cheap regression guard. Do not combine it with fake timers: axe schedules its own work and will time out.
// src/components/__tests__/FilterBar.spec.ts
import { mount } from '@vue/test-utils'
import axe from 'axe-core'
import { expect, test } from 'vitest'
import FilterBar from '../FilterBar.vue'
test('FilterBar has no axe violations', async () => {
const wrapper = mount(FilterBar, { attachTo: document.body })
const results = await axe.run(wrapper.element as HTMLElement)
expect(results.violations.map((violation) => violation.id)).toEqual([])
wrapper.unmount()
})
// Output: passes. Delete the <label> for the select and it fails
// with: expected [ 'select-name' ] to deeply equal []A page-level check in Playwright
This is the pass a component test cannot do: real layout, so colour contrast is genuinely measured, plus duplicate ids and heading order across the whole composed page. withTags limits the run to the WCAG levels you are committing to, which keeps best-practice rules from failing the build before you have decided to adopt them. AxeBuilder also takes include() and exclude() with CSS selectors, which is how you ignore a third-party widget you cannot fix while still checking the rest. Waiting for the heading first makes sure you are not auditing a loading skeleton.
// e2e/a11y.spec.ts
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
test('the tickets page has no WCAG A or AA violations', async ({ page }) => {
await page.goto('/tickets')
await page.getByRole('heading', { name: 'Tickets', level: 1 }).waitFor()
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze()
expect(results.violations).toEqual([])
})A keyboard path asserted end to end
axe cannot see any of this: it checks markup, not behaviour over time. Here focus and keys are the whole test, and getByRole with a name is the same tree a screen reader reads, so a control that loses its accessible name breaks this test too. A <select> exposes the combobox role, which is why the locator is not getByRole('listbox'). Write one of these for each dialog, menu or drawer; they are the tests that catch a modal you can Tab out of and a close button that drops focus on <body>.
// e2e/keyboard.spec.ts
import { expect, test } from '@playwright/test'
test('the filter dialog opens and closes from the keyboard', async ({ page }) => {
await page.goto('/tickets')
const trigger = page.getByRole('button', { name: 'Filter tickets' })
await trigger.focus()
await page.keyboard.press('Enter')
const dialog = page.getByRole('dialog', { name: 'Filter tickets' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('combobox', { name: 'Status' })).toBeFocused()
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(trigger).toBeFocused()
})
Discussion