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.
After this you will be able to
- add a
Modalcomponent built on the native<dialog>: Escape, backdrop click, scroll lock and focus return - label the dialog with
aria-labelledbyandaria-describedbyusing ids fromuseId - 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
Know beforehand
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
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 theinertattribute
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
cancelandcloseevents
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 beforeshowModal()
The dialog has a name and a description
- Who handles it in this recipe
- The component:
aria-labelledbypoints to the heading,aria-describedbyto the explanatory text
A backdrop click closes the dialog
- Who handles it in this recipe
- The component:
pointerdownandclickhandlers 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.
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>
)
}showModal()runs in the effect and the cleanup callsclose(), so the dialog closes both whenopenbecomesfalseand on unmount. Callingclose()on a closed dialog does nothing, which makes the StrictMode double run safe.- The
closeevent fires on every close — Escape, the “×” button, the backdrop, a<form method="dialog">submit. TheonClosehandler sets the parent state back tofalse, 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, soevent.target === event.currentTargetmeans “outside the dialog”. - The
pointerdowncheck 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. useIdproduces unique ids foraria-labelledbyandaria-describedby, so several dialogs on one page never clash.
.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);
}
}
}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
Copy two files
Modal.tsxandModal.cssgo intosrc/components/. No portals orz-indexneeded: a modal dialog is rendered in the top layer above the whole page.Keep the state in the parent
const [open, setOpen] = useState(false), then passopenandonClose={() => setOpen(false)}to the dialog.Mark the initial focus
Put
data-autofocuson the field or button the user starts with. Without it the browser focuses the first focusable element, which is the “×” button.Close the dialog after the action
After a successful form submit call
setOpen(false): the effect callsclose()and focus returns to the “Book now” button.
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>
)
}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.
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 webkitimport 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' }],
},
},
})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([])
})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.
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'] } },
],
})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([])
})npx vitest run # component tests in Chromium
npx playwright test # scenarios in Chromium, Firefox and WebKit- 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.
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>
</>
)
}- APG covers short, critical confirmations with a separate Alert Dialog pattern and the
alertdialogrole. If you need it, add aroleprop to the component and passrole="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’svaluelands inreturnValue. This works with the component — the close arrives through the samecloseevent; 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
openattribute and do not puttabindexon<dialog>: MDN recommendsshowModal()and forbidstabindexon 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.