How to buildOctober 12, 20268 min read
ReactStep 8 of 8

Recipe: an accessible modal dialog in React

In short

Use the native <dialog> and open it with showModal() from a React effect: the browser puts the dialog in the top layer, makes the page underneath inert, closes it on Escape and returns focus to whatever had it before the dialog opened. The component only has to sync state through the close event, close on a backdrop click, label the dialog with aria-labelledby and lock scrolling with a single CSS rule. Below are a ready-made Modal.tsx, styles that respect prefers-reduced-motion, and tests that run in a real browser.

Code recipeLevel: IntermediateReact ComponentsPractice: ~40–90 min

After this you will be able to

  • add a Modal component built on the native <dialog>: Escape, backdrop click, scroll lock and focus return
  • label the dialog with aria-labelledby and aria-describedby using ids from useId
  • test the dialog with Vitest in browser mode and with Playwright scenarios plus axe
  • build a confirmation dialog on top of the component with focus on the safe action

Verified with

React 19.3.0TypeScript 6.0.3, 7.0.2Vite 8.3.4Vitest 5.0.3 (@vitest/browser-playwright 5.0.3)@testing-library/react 16.3.3axe-core 4.14.0, @axe-core/playwright 4.13.0Playwright 1.64.0: Chromium 156, Firefox 157, WebKit 27.2Node.js 24 LTS (24.18.0)

Checked against docs: October 12, 2026

Contents
  1. 01The task and what the dialog must do
  2. 02The component: Modal.tsx and its styles
  3. 03How to add it
  4. 04How to check the result
  5. 05Variants: confirmation and forms
  6. 06Limitations and what the recipe builds on

The task and what the dialog must do

The task: a booking, confirmation or short-form dialog that works equally well with a mouse, a keyboard and a screen reader. The requirements come from the Dialog (Modal) pattern in the WAI-ARIA Authoring Practices (APG): the page underneath is unavailable, focus moves inside on open, Escape closes the dialog, focus returns to the button that opened it, and the dialog has a label and a visible close button.

The page underneath is unreachable by mouse, keyboard and screen reader

Who handles it in this recipe
The browser: showModal() makes the rest of the document inert, like the inert attribute

Focus moves inside on open

Who handles it in this recipe
The browser focuses the first focusable element; the component focuses the one with data-autofocus

Escape closes the dialog

Who handles it in this recipe
The browser: a close request, then the cancel and close events

After closing, focus goes back where it was

Who handles it in this recipe
The browser: per the HTML standard, close() returns focus to the element focused before showModal()

The dialog has a name and a description

Who handles it in this recipe
The component: aria-labelledby points to the heading, aria-describedby to the explanatory text

A backdrop click closes the dialog

Who handles it in this recipe
The component: pointerdown and click handlers on the <dialog>

The page does not scroll under the dialog

Who handles it in this recipe
CSS: :root:has(.modal:modal) { overflow: hidden }

Animation does not bother people who asked for less motion

Who handles it in this recipe
CSS: transitions live only inside prefers-reduced-motion: no-preference

The component: Modal.tsx and its styles

The component is controlled: the parent owns the open prop and learns about every close through onClose. The dialog opens inside an effect — that is React synchronising with an external system, the browser API.

src/components/Modal.tsx
import { useEffect, useId, useRef, type ReactNode } from 'react'
import './Modal.css'

type ModalProps = {
  open: boolean
  onClose: () => void
  title: string
  description?: string
  closeOnBackdrop?: boolean
  closeLabel?: string
  children: ReactNode
}

export function Modal({
  open,
  onClose,
  title,
  description,
  closeOnBackdrop = true,
  closeLabel = 'Close',
  children,
}: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const pressedOnBackdrop = useRef(false)
  const titleId = useId()
  const descriptionId = useId()

  useEffect(() => {
    const dialog = dialogRef.current
    if (!open || !dialog) return

    dialog.showModal() // top layer, ::backdrop, the page underneath becomes inert
    dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus()

    // close() returns focus to the element that had it before showModal()
    return () => dialog.close()
  }, [open])

  return (
    <dialog
      ref={dialogRef}
      className="modal"
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
      // every close (Esc, button, backdrop, form method="dialog") ends up here
      onClose={onClose}
      onPointerDown={(event) => {
        pressedOnBackdrop.current = event.target === event.currentTarget
      }}
      onClick={(event) => {
        // backdrop click: both press and release happened outside the content
        if (closeOnBackdrop && pressedOnBackdrop.current && event.target === event.currentTarget) {
          event.currentTarget.close()
        }
      }}
    >
      <div className="modal__body">
        <div className="modal__header">
          <h2 id={titleId} className="modal__title">
            {title}
          </h2>
          <button
            type="button"
            className="modal__close"
            aria-label={closeLabel}
            onClick={() => dialogRef.current?.close()}
          >
            ×
          </button>
        </div>
        {description && <p id={descriptionId}>{description}</p>}
        {children}
      </div>
    </dialog>
  )
}
The ready-made component: no dependencies besides React.
  • showModal() runs in the effect and the cleanup calls close(), so the dialog closes both when open becomes false and on unmount. Calling close() on a closed dialog does nothing, which makes the StrictMode double run safe.
  • The close event fires on every close — Escape, the “×” button, the backdrop, a <form method="dialog"> submit. The onClose handler sets the parent state back to false, so React and the browser never disagree.
  • The browser targets a backdrop click at the <dialog> itself. The dialog has zero padding and all content sits in the inner .modal__body, so event.target === event.currentTarget means “outside the dialog”.
  • The pointerdown check protects against “selected text in a field and released the mouse over the backdrop”. Without it, in our tests that gesture closed the dialog in Chromium and WebKit.
  • useId produces unique ids for aria-labelledby and aria-describedby, so several dialogs on one page never clash.
src/components/Modal.css
.modal {
  width: min(32rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
  padding: 0; /* padding lives on .modal__body, so a click on the <dialog> itself means the backdrop */
  border: none;
  border-radius: 12px;
  background: #fff;
  color: #1a1a1a;
}

.modal::backdrop {
  background: rgb(0 0 0 / 0.5);
}

.modal__body {
  padding: 1.5rem;
}

.modal__header {
  display: flex;
  align-items: start;
  justify-content: space-between;
  gap: 1rem;
}

.modal__title {
  margin: 0;
  font-size: 1.25rem;
}

.modal__close {
  min-width: 44px;
  min-height: 44px;
  border: none;
  background: transparent;
  font-size: 1.5rem;
  cursor: pointer;
}

/* lock page scrolling while a modal dialog is open */
:root:has(.modal:modal) {
  overflow: hidden;
}

/* animate only if the user has not asked for reduced motion */
@media (prefers-reduced-motion: no-preference) {
  .modal,
  .modal::backdrop {
    transition:
      opacity 200ms ease-out,
      transform 200ms ease-out,
      overlay 200ms ease-out allow-discrete,
      display 200ms ease-out allow-discrete;
  }

  .modal,
  .modal::backdrop {
    opacity: 0;
  }

  .modal {
    transform: translateY(8px);
  }

  .modal[open],
  .modal[open]::backdrop {
    opacity: 1;
    transform: none;
  }

  @starting-style {
    .modal[open],
    .modal[open]::backdrop {
      opacity: 0;
    }

    .modal[open] {
      transform: translateY(8px);
    }
  }
}
Dialog and backdrop styles, scroll lock and animation.

The animation uses CSS transitions. @starting-style sets the initial state on entry, while display and overlay with allow-discrete keep the dialog on screen until the exit transition ends. According to MDN, overlay does not work in every browser yet — there the dialog simply disappears without an exit animation. The whole block sits inside @media (prefers-reduced-motion: no-preference): with “reduce motion” switched on in the OS, the dialog appears instantly.

How to add it

  1. Copy two files

    Modal.tsx and Modal.css go into src/components/. No portals or z-index needed: a modal dialog is rendered in the top layer above the whole page.

  2. Keep the state in the parent

    const [open, setOpen] = useState(false), then pass open and onClose={() => setOpen(false)} to the dialog.

  3. Mark the initial focus

    Put data-autofocus on the field or button the user starts with. Without it the browser focuses the first focusable element, which is the “×” button.

  4. Close the dialog after the action

    After a successful form submit call setOpen(false): the effect calls close() and focus returns to the “Book now” button.

src/App.tsx
import { useState, type FormEvent } from 'react'
import { Modal } from './components/Modal'

export default function App() {
  const [open, setOpen] = useState(false)
  const [status, setStatus] = useState('')

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const phone = String(new FormData(event.currentTarget).get('phone'))
    // send the request to the server here
    setStatus(`Request received, we will call ${phone}`)
    setOpen(false)
  }

  return (
    <main>
      <h1>Beauty salon</h1>
      <button type="button" onClick={() => setOpen(true)}>
        Book now
      </button>
      <p role="status">{status}</p>

      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Book an appointment"
        description="Leave your phone number and the front desk will call you back to find a time."
      >
        <form onSubmit={handleSubmit}>
          <label htmlFor="phone">Phone</label>
          <input id="phone" name="phone" type="tel" autoComplete="tel" required data-autofocus />
          <button type="submit">Send</button>
        </form>
      </Modal>
    </main>
  )
}
Usage example: a salon booking with a form inside the dialog.

How to check the result

jsdom, the usual Vitest test environment, does not implement showModal() and close() as of version 30.1.2: the “Implement HTMLDialogElement” issue in the jsdom tracker is still open. So we test the component in Vitest browser mode: the same Testing Library, but in a real Chromium driven by Playwright, with a real keyboard and real focus.

bash
npm create vite@latest modal-demo -- --template react-ts
cd modal-demo
npm install
npm install -D vitest @vitest/browser-playwright @testing-library/react @testing-library/dom axe-core
npm install -D @playwright/test @axe-core/playwright
npx playwright install chromium firefox webkit
A scratch project: the Vite react-ts template, Vitest in browser mode, Playwright and axe.
vitest.config.ts
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [react()],
  test: {
    include: ['src/**/*.test.tsx'],
    browser: {
      enabled: true,
      provider: playwright(),
      headless: true,
      instances: [{ browser: 'chromium' }],
    },
  },
})
Vitest runs the tests from src/ in Chromium; add firefox and webkit to instances if you need them.
src/components/Modal.test.tsx
import { cleanup, render, screen } from '@testing-library/react'
import axe from 'axe-core'
import { useState } from 'react'
import { afterEach, expect, test } from 'vitest'
import { page, userEvent } from 'vitest/browser'
import { Modal } from './Modal'

function Demo() {
  const [open, setOpen] = useState(false)
  return (
    <main>
      <button type="button" onClick={() => setOpen(true)}>
        Book now
      </button>
      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Book an appointment"
        description="Leave your phone number and we will call you back."
      >
        <label htmlFor="phone">Phone</label>
        <input id="phone" type="tel" data-autofocus />
      </Modal>
    </main>
  )
}

afterEach(cleanup)

async function openModal() {
  render(<Demo />)
  const opener = screen.getByRole('button', { name: 'Book now' })
  opener.focus() // open from the keyboard: this is how focus return is tested
  await userEvent.keyboard('{Enter}')
  const dialog = screen.getByRole<HTMLDialogElement>('dialog', { name: 'Book an appointment' })
  return { opener, dialog }
}

test('opens modally, with a label, a description and focus inside', async () => {
  const { dialog } = await openModal()
  expect(dialog.matches(':modal')).toBe(true)
  expect(dialog).toHaveAccessibleDescription('Leave your phone number and we will call you back.')
  expect(document.activeElement).toBe(screen.getByLabelText('Phone'))
  expect(getComputedStyle(document.documentElement).overflow).toBe('hidden')
})

test('Escape closes the dialog and returns focus to the button', async () => {
  const { opener, dialog } = await openModal()
  await userEvent.keyboard('{Escape}')
  await expect.poll(() => dialog.open).toBe(false)
  await expect.poll(() => document.activeElement).toBe(opener)
  expect(getComputedStyle(document.documentElement).overflow).not.toBe('hidden')

  // React state is in sync: the dialog opens again
  await userEvent.keyboard('{Enter}')
  expect(dialog.open).toBe(true)
})

test('the Close button closes the dialog', async () => {
  const { opener, dialog } = await openModal()
  await userEvent.click(screen.getByRole('button', { name: 'Close' }))
  await expect.poll(() => dialog.open).toBe(false)
  expect(document.activeElement).toBe(opener)
})

test('a backdrop click closes it, a click inside does not', async () => {
  const { dialog } = await openModal()
  await userEvent.click(screen.getByText('Leave your phone number and we will call you back.'))
  expect(dialog.open).toBe(true)

  // a point 10 px left of and above the dialog is the ::backdrop
  await userEvent.click(page.elementLocator(dialog), { position: { x: -10, y: -10 } })
  await expect.poll(() => dialog.open).toBe(false)
})

test('axe: no accessibility violations in the open dialog', async () => {
  const { dialog } = await openModal()
  // wait for the enter animation: on semi-transparent text axe reports low contrast
  await Promise.all(dialog.getAnimations({ subtree: true }).map((animation) => animation.finished))
  const results = await axe.run(dialog)
  expect(results.violations).toEqual([])
})
Five checks: modality, name and description, initial focus, Escape, the button, the backdrop, axe.

The tests do catch regressions: we removed onClose, the backdrop check, data-autofocus, aria-describedby and the scroll-lock rule one at a time, and swapped showModal() for show() — each time at least one test failed. Waiting for the animation before axe is deliberate: without it axe sometimes catches semi-transparent text and reports insufficient contrast.

Whole scenarios — keyboard, wheel scrolling, prefers-reduced-motion, form submit and axe over the entire page — run end to end in Playwright across three engines. The Vite dev server renders the app in StrictMode, so the double effect run is covered too.

playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://localhost:5181' },
  webServer: { command: 'npm run dev -- --port 5181 --strictPort', url: 'http://localhost:5181' },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
})
Playwright starts the dev server and runs the scenarios in Chromium, Firefox and WebKit.
e2e/modal.spec.ts
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'

test.beforeEach(async ({ page }) => {
  await page.goto('/')
})

test('keyboard: focus moves in, Tab stays off the page, Escape returns focus', async ({ page }) => {
  const opener = page.getByRole('button', { name: 'Book now' })
  await opener.focus()
  await page.keyboard.press('Enter')

  const dialog = page.getByRole('dialog', { name: 'Book an appointment' })
  await expect(dialog).toBeVisible()
  await expect(page.getByLabel('Phone')).toBeFocused()

  for (let i = 0; i < 6; i++) {
    await page.keyboard.press('Tab')
    const leaked = await page.evaluate(() => {
      const active = document.activeElement
      return active !== null && active !== document.body && !document.querySelector('dialog')!.contains(active)
    })
    expect(leaked).toBe(false)
  }

  await page.keyboard.press('Escape')
  await expect(dialog).toBeHidden()
  await expect(opener).toBeFocused()
})

test('page scrolling is locked while the dialog is open', async ({ page }) => {
  await page.evaluate(() => (document.body.style.minHeight = '300vh'))
  await page.getByRole('button', { name: 'Book now' }).click()
  await page.mouse.move(20, 20)
  await page.mouse.wheel(0, 600)
  await page.waitForTimeout(300)
  expect(await page.evaluate(() => window.scrollY)).toBe(0)

  await page.keyboard.press('Escape')
  await page.mouse.wheel(0, 600)
  await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0)
})

test('prefers-reduced-motion: reduce turns the transition off', async ({ page }) => {
  const duration = () => page.locator('dialog').evaluate((el) => getComputedStyle(el).transitionDuration)
  expect(await duration()).not.toBe('0s')
  await page.emulateMedia({ reducedMotion: 'reduce' })
  expect(await duration()).toBe('0s')
})

test('form: submitting closes the dialog and returns focus', async ({ page }) => {
  const opener = page.getByRole('button', { name: 'Book now' })
  await opener.focus()
  await page.keyboard.press('Enter')
  await page.getByLabel('Phone').fill('+7 700 000 00 00')
  await page.keyboard.press('Enter')
  await expect(page.getByRole('dialog')).toBeHidden()
  await expect(page.getByRole('status')).toHaveText('Request received, we will call +7 700 000 00 00')
  await expect(opener).toBeFocused()
})

test('axe: the page with an open dialog has no violations', async ({ page }) => {
  await page.getByRole('button', { name: 'Book now' }).click()
  const dialog = page.getByRole('dialog', { name: 'Book an appointment' })
  await expect(dialog).toBeVisible()
  await dialog.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)))
  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})
Browser scenarios: keyboard, scroll lock, reduced motion, the form, axe.
bash
npx vitest run        # component tests in Chromium
npx playwright test    # scenarios in Chromium, Firefox and WebKit
Our run: Vitest 18 of 18 in Chromium, Firefox and WebKit (including the confirmation dialog test), Playwright 15 of 15.
  • Tab to “Book now”, press Enter — the dialog opens with focus in the “Phone” field
  • Tab around — focus never lands on elements of the page underneath
  • Escape — the dialog closes and focus is back on “Book now”
  • The mouse wheel does not scroll the page while the dialog is open
  • The browser’s Accessibility panel shows role dialog, the name “Book an appointment” and a description
  • With “reduce motion” switched on, the dialog appears without animation

Variants: confirmation and forms

For a step that is hard to undo, APG advises putting initial focus on the least destructive action. A confirmation dialog is built from the same Modal: focus goes to “Keep booking” and a backdrop click does not close it.

src/components/CancelBookingButton.tsx
import { useState } from 'react'
import { Modal } from './Modal'

export function CancelBookingButton({ onConfirm }: { onConfirm: () => void }) {
  const [open, setOpen] = useState(false)

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Cancel booking
      </button>
      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Cancel this booking?"
        description="The slot will be released and another client can take it."
        closeOnBackdrop={false}
      >
        {/* focus goes to the safe action (APG advice for irreversible steps) */}
        <button type="button" data-autofocus onClick={() => setOpen(false)}>
          Keep booking
        </button>
        <button
          type="button"
          onClick={() => {
            onConfirm()
            setOpen(false)
          }}
        >
          Yes, cancel
        </button>
      </Modal>
    </>
  )
}
Confirming a booking cancellation; its test checks focus, the backdrop and the onConfirm call.
  • APG covers short, critical confirmations with a separate Alert Dialog pattern and the alertdialog role. If you need it, add a role prop to the component and pass role="alertdialog".
  • A long form with unsaved input: closeOnBackdrop={false}, so a stray click outside does not wipe what was typed. Keep Escape — MDN reminds that keyboard users expect it.
  • A dialog without its own submit logic: <form method="dialog"> closes it on submit and the clicked button’s value lands in returnValue. This works with the component — the close arrives through the same close event; we checked it with a separate test.
  • The closedby="any" attribute enables closing on an outside click without JavaScript, but according to MDN it is not yet supported in all major browsers — hence the manual backdrop handling.

Limitations and what the recipe builds on

  • Tab: focus never reaches the page content because it is inert. In Chromium and WebKit, though, after the last element focus steps out of the document into the browser UI for one press and then comes back to the first element. APG describes a strict wrap inside the dialog; if you need exactly that, you will have to add your own focus trap.
  • We checked the scroll lock in Chromium 156, Firefox 157 and WebKit 27.2 under Playwright. We did not check it on real iOS or Android devices.
  • If the button that opened the dialog disappears together with the action (say, a table row is deleted), the browser has nowhere to return focus. Per APG, move it to the next logical element yourself in onClose.
  • According to MDN, a second dialog opened over the first one goes above it in the top layer. We did not test that scenario separately.
  • Do not open the dialog via the open attribute and do not put tabindex on <dialog>: MDN recommends showModal() and forbids tabindex on this element.

The basics this recipe relies on: effects, cleanup and StrictMode are in “useEffect: synchronising with the outside world, and when you don’t need it”; native elements, focus management and ARIA are in “Accessibility in code: semantics, focus, ARIA”; the pointerdown and click events and the difference between target and currentTarget are in “JavaScript in the browser: the DOM and events”.

How this was verified5 · passedchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.