Saltar al contenido
Saltar al texto del artículo

Accessibility

Playwright Accessibility Checks That Teams Keep Running

Run tested Playwright and axe examples for page scans, dialog focus, and skip links, then use a keyboard checklist to review what automation cannot prove.

Escrito por

Publicado
Actualizado
Lectura
7 min de lectura
Cantidad de palabras
1,244 palabras

Artículo en inglés

De la serie · Artículo 8 de 8

Building a Production Next.js App
En esta página 6 secciones

Guide

A page can pass an automated accessibility scan while its dialog loses keyboard focus. A skip link can look correct while leaving focus in the header. I want tests that tell me whether a person can complete the next step.

This guide combines axe scans with explicit keyboard assertions against this website. The code blocks below render the same test files that run in its Playwright suite. You can reproduce them locally, then adapt the routes, accessible names, and expected focus order to your own application.

Run the examples locally

Use Node.js 24 or later and the repository’s pinned pnpm 11.25.0. Clone the website repository1, or use an existing checkout containing this guide:

bashterminal
git clone https://github.com/santi020k/website.git
cd website
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
pnpm run build
SKIP_BUILD=1 pnpm run test:e2e tests/accessibility-guide --project=chromium --workers=1 --retries=0

In an existing checkout, skip the clone and directory-change commands. The repository already includes Playwright and @axe-core/playwright. Its configuration starts the production preview and supplies baseURL; the tests use local routes, not the public website. The search tests intercept only the search-index response to make empty and failure states repeatable.

For an existing Playwright project, install @axe-core/playwright as a development dependency with your package manager. Copy an example into your test directory and adapt its application contract. These selectors describe this site’s search UI; they are not universal dialog selectors.

Scan a stable page in both themes

This example waits for a visible page heading and loaded fonts, then checks the article in light and dark themes. It requests axe’s WCAG 2.0, 2.1, and 2.2 A/AA rule tags. A passing assertion means those enabled rules found no violations in the scanned state; it does not establish WCAG conformance.

typescripttests/accessibility-guide/scan.spec.ts
Ver el ejemplo completoContraer ejemplo25 líneas · Se copia el ejemplo completo
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

for (const theme of ['light', 'dark']) {
  test(`article has no detected WCAG A/AA violations in ${theme}`, async ({ page }, testInfo) => {
    await page.emulateMedia({ reducedMotion: 'reduce' })
    await page.addInitScript(value => {
      localStorage.setItem('theme', value)
    }, theme)
    await page.goto('/blog/playwright-accessibility-checks-that-teams-keep-running/')
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
    await expect(page.locator('html')).toHaveAttribute('data-theme', theme)
    await page.evaluate(() => document.fonts.ready.then(() => undefined))

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
      .analyze()

    await testInfo.attach(`axe-${theme}`, {
      body: JSON.stringify({ violations: results.violations, incomplete: results.incomplete }, null, 2),
      contentType: 'application/json'
    })
    expect(results.violations).toEqual([])
  })
}
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

for (const theme of ['light', 'dark']) {
  test(`article has no detected WCAG A/AA violations in ${theme}`, async ({ page }, testInfo) => {
    await page.emulateMedia({ reducedMotion: 'reduce' })
    await page.addInitScript(value => {
      localStorage.setItem('theme', value)
    }, theme)
    await page.goto('/blog/playwright-accessibility-checks-that-teams-keep-running/')
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
    await expect(page.locator('html')).toHaveAttribute('data-theme', theme)

Keep the incomplete results for human review: they are checks axe could not decide automatically. The attachment also preserves violation details for diagnosis. Use synthetic accounts and data in authenticated tests, because reports and traces can contain page content.

Reduced motion makes this particular scan deterministic. Test the ordinary motion path separately, especially when transitions affect focus or expose controls. Wait for meaningful UI states instead of adding a fixed sleep. A loading state that people can interact with also deserves its own checks.

The Playwright accessibility guide2 explains axe integration and scan scope. Static diagnostics such as Astro Doctor’s source checks3 complement this browser coverage by catching some omissions earlier.

Test the whole dialog focus cycle

The first test below uses an empty search index, leaving two tabbable controls: Close and the search field. It checks both boundary wraps, movement within the dialog, Escape, focus return, and keyboard activation of the Close button after reopening.

Calling focus() deliberately isolates this component test. It does not prove that a visitor can reach the opener from the page’s first Tab stop. The skip-link example in the next section starts from a fresh page without that shortcut.

typescripttests/accessibility-guide/dialog.spec.ts
Ver el ejemplo completoContraer ejemplo64 líneas · Se copia el ejemplo completo
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

const tabKeys = {
  chromium: { next: 'Tab', previous: 'Shift+Tab' },
  firefox: { next: 'Tab', previous: 'Shift+Tab' },
  webkit: process.platform === 'darwin' ?
    { next: 'Alt+Tab', previous: 'Alt+Shift+Tab' } :
    { next: 'Tab', previous: 'Shift+Tab' }
}

test('search wraps focus in both directions and restores its opener', async ({ page, browserName }) => {
  // An empty index keeps this example's tab sequence to Close and Search.
  await page.route('**/search-index.json', route => route.fulfill({ json: [] }))
  await page.emulateMedia({ reducedMotion: 'reduce' })
  await page.goto('/')
  await expect(page.locator('[data-site-search]')).toHaveAttribute('data-site-search-bound', 'true')

  const opener = page.getByRole('button', { name: 'Open site search (Ctrl/⌘ K)', exact: true })
  const dialog = page.getByRole('dialog', { name: 'Search', exact: true })
  const input = dialog.getByRole('searchbox')
  const close = dialog.getByRole('button', { name: 'Close search', exact: true })
  const { next, previous } = tabKeys[browserName]

  // Focus is setup for this component test, not proof of opener reachability.
  await opener.focus()
  await page.keyboard.press('Enter')
  await expect(dialog).toBeVisible()
  await expect(dialog.getByRole('heading', { name: 'Start typing to search' })).toBeVisible()
  await expect(input).toBeFocused()
  await page.keyboard.press(next)
  await expect(close).toBeFocused()
  await page.keyboard.press(previous)
  await expect(input).toBeFocused()
  await page.keyboard.press(previous)
  await expect(close).toBeFocused()
  await page.keyboard.press(next)
  await expect(input).toBeFocused()
  await page.keyboard.press('Escape')
  await expect(dialog).toBeHidden()
  await expect(opener).toBeFocused()

  await page.keyboard.press('Space')
  await expect(input).toBeFocused()
  await page.keyboard.press(previous)
  await expect(close).toBeFocused()
  await page.keyboard.press('Enter')
  await expect(dialog).toBeHidden()
  await expect(opener).toBeFocused()
})

test('search failure exposes an accessible retry action', async ({ page }) => {
  await page.route('**/search-index.json', route => route.fulfill({ status: 503, body: 'Unavailable' }))
  await page.emulateMedia({ reducedMotion: 'reduce' })
  await page.goto('/')
  await page.getByRole('button', { name: 'Open site search (Ctrl/⌘ K)', exact: true }).click()

  const dialog = page.getByRole('dialog', { name: 'Search', exact: true })
  await expect(dialog.getByRole('heading', { name: 'Search unavailable' })).toBeVisible()
  await expect(dialog.getByRole('button', { name: 'Try again', exact: true })).toBeVisible()

  const results = await new AxeBuilder({ page }).include('#site-search-dialog').analyze()
  expect(results.violations).toEqual([])
})
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

const tabKeys = {
  chromium: { next: 'Tab', previous: 'Shift+Tab' },
  firefox: { next: 'Tab', previous: 'Shift+Tab' },
  webkit: process.platform === 'darwin' ?
    { next: 'Alt+Tab', previous: 'Alt+Shift+Tab' } :
    { next: 'Tab', previous: 'Shift+Tab' }
}

test('search wraps focus in both directions and restores its opener', async ({ page, browserName }) => {

The second test scans the visible failure state, including the retry control. Its include() limits the audit to the dialog; keep the full-page scan as well. Checking that the retry action exists does not prove retry recovery or its announcement: those need their own interaction and assistive-technology checks.

WebKit on macOS uses Option-Tab in this suite to include links and buttons. Match keyboard-navigation settings to the browser and operating system you support, and record them during manual testing.

Initial focus is a product decision. A search dialog can start in its input; a long informational dialog may need focus on its heading, and a destructive confirmation may start on its safest action. The WAI-ARIA dialog pattern4 describes those choices and the expected keyboard cycle.

This is the interaction contract behind Lumen’s shared accessibility foundations5. A component library helps establish the behavior; each application still needs to verify its composition, content, and focus return destination.

This example uses only keyboard input after navigation. It checks the first stop, activates the skip link, verifies focus on the main landmark, then continues to the first link in that landmark. A changed URL fragment alone would not prove this behavior.

typescripttests/accessibility-guide/skip-link.spec.ts
Ver el ejemplo completoContraer ejemplo62 líneas · Se copia el ejemplo completo
import { expect, test } from '@playwright/test'

const nextKeys = {
  chromium: 'Tab',
  firefox: 'Tab',
  webkit: process.platform === 'darwin' ? 'Alt+Tab' : 'Tab'
}

test('the first Tab reaches the skip link and Enter moves focus to main', async ({ page, browserName }) => {
  await page.goto('/')
  const next = nextKeys[browserName]

  await page.keyboard.press(next)
  const skip = page.getByRole('link', { name: 'Skip to content', exact: true })
  await expect(skip).toBeFocused()
  await expect(skip).toBeVisible()
  await page.keyboard.press('Enter')
  await expect(page.getByRole('main')).toBeFocused()
  await page.keyboard.press(next)
  await expect(page.getByRole('main').getByRole('link').first()).toBeFocused()
})

for (const path of ['/', '/blog/ai-coding-is-probabilistic-your-delivery-process-should-not-be/', '/portfolio/lumen-ui/']) {
  test(`the first Tab inside main lands on a visible control on ${path}`, async ({ page, browserName }) => {
    await page.goto(path)
    const next = nextKeys[browserName]

    await page.keyboard.press(next)
    await expect(page.getByRole('link', { name: 'Skip to content', exact: true })).toBeFocused()
    await page.keyboard.press('Enter')
    await expect(page.getByRole('main')).toBeFocused()
    await page.keyboard.press(next)

    // Playwright counts a 1px clipped `.sr-only` node as visible, so measure the
    // focused control itself: it must be painted and keyboard focus must show.
    const focused = await page.evaluate(() => {
      const element = document.activeElement

      if (!(element instanceof HTMLElement)) return null

      const { height, width } = element.getBoundingClientRect()
      const style = getComputedStyle(element)

      return {
        clipped: style.getPropertyValue('clip') !== 'auto' || style.clipPath !== 'none',
        focusVisible: element.matches(':focus-visible'),
        height,
        inMain: document.getElementById('main')?.contains(element) === true,
        painted: element.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
        width
      }
    })

    expect(focused).not.toBeNull()
    expect(focused?.inMain).toBe(true)
    expect(focused?.painted).toBe(true)
    expect(focused?.clipped).toBe(false)
    expect(focused?.focusVisible).toBe(true)
    expect(focused?.width).toBeGreaterThan(8)
    expect(focused?.height).toBeGreaterThan(8)
  })
}
import { expect, test } from '@playwright/test'

const nextKeys = {
  chromium: 'Tab',
  firefox: 'Tab',
  webkit: process.platform === 'darwin' ? 'Alt+Tab' : 'Tab'
}

test('the first Tab reaches the skip link and Enter moves focus to main', async ({ page, browserName }) => {
  await page.goto('/')
  const next = nextKeys[browserName]

The final link expectation is specific to this homepage. In another application, name the first meaningful control after the skip destination. For a purchase, sign-in, or publishing journey, extend the sequence through completion and recovery without using click(), focus(), or fill() to bypass keyboard access.

Keyboard-testing checklist

Run this on the important routes and states in your supported desktop browsers. Start with a fresh page and put the pointer aside. Record the browser, operating system, keyboard settings, theme, zoom level, and any failure’s reproduction steps.

  1. Entry:
    The first Tab reveals a usable skip link. Enter moves focus to the intended content, and the next Tab continues there.
  2. Order:
    Tab and Shift+Tab follow a logical sequence. Hidden or inert controls do not receive focus, and ordinary page content does not trap it.
  3. Visibility:
    Focus stays clearly visible, including beneath sticky headers, inside scroll regions, and at enlarged text or zoom. Check both themes.
  4. Activation:
    Enter activates links. Enter and Space activate buttons. Use arrow keys for composite widgets where their documented pattern requires them.
  5. Dialogs:
    Opening moves focus to a useful destination. Both boundary wraps stay inside the modal. Escape and the visible close action work, and closing returns focus to the opener or a logical successor if it no longer exists.
  6. Forms:
    Complete the flow using the keyboard. Trigger validation errors, reach the invalid fields, correct them, and submit again without losing progress or focus.
  7. Dynamic states:
    Exercise loading, no results, network failure, and retry. Focus remains useful as controls appear, disappear, or become disabled.
  8. Navigation:
    After a route change, the focus destination and page title help orient the visitor. Browser Back leaves the page operable.
  9. Content access:
    Scroll long code examples and other overflow regions from the keyboard, then leave them normally. Inspect the page at 200% zoom and a narrow viewport for clipped controls.

This is a focused review aid, not a complete conformance checklist. Pair it with manual screen-reader checks for names, roles, reading order, errors, and live announcements, plus testing with disabled users where possible. DOM assertions and screenshots cannot prove what an assistive-technology user hears. W3C’s preliminary accessibility checks6 provide another starting point for manual assessment.

Keep the checks useful in CI

Run these tests with the existing browser suite and retain actionable reports when they fail. For a supported critical flow, an unexpected violation or broken keyboard step should fail the check. Do not filter failures by severity just to obtain a green build.

Start with a small set of complete journeys, fix their findings, and expand coverage. If a legacy issue needs an exception, record its exact rule and affected element, owner, and removal condition. Avoid excluding a whole page or disabling a rule across the application.

Keep evidence precise: this suite exercises browser behavior and automated rules. Manual screen-reader verification and a full WCAG audit remain separate work.

For component-level behavior, continue with Vitest and React Testing Library7. For the release gate around these checks, see a release process that reduces drama8.

Capítulo publicado más recienteArtículo 8 de 8

Estás en el capítulo más reciente.

Building a Production Next.js App

Vuelve a un capítulo anterior o explora el resumen de la serie.

Los 8 capítulos

Compartir

Compártelo.

Comentarios

Súmate a la conversación.

Lee las respuestas, haz una pregunta o comparte otra perspectiva.

Abrir conversaciónOcultar conversación

References

  1. website repositorygithub.com/santi020k/website
  2. Playwright accessibility guideplaywright.dev/docs/accessibility-testing
  3. Astro Doctor’s source checkssanti020k.com/blog/astro-doctor-announcement/
  4. WAI-ARIA dialog patternwww.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
  5. Lumen’s shared accessibility foundationssanti020k.com/blog/lumen-ui-one-system-three-frameworks/#accessibility-belongs-in-the-primitive
  6. W3C’s preliminary accessibility checkswww.w3.org/WAI/test-evaluate/preliminary/
  7. Vitest and React Testing Librarysanti020k.com/blog/testing-react-components-with-vitest-and-react-testing-library/
  8. a release process that reduces dramasanti020k.com/blog/a-release-process-that-reduces-drama/

Playwright Accessibility Checks That Teams Keep Runninghttps://santi020k.com/es/blog/playwright-accessibility-checks-that-teams-keep-running/