Возьмите нативный <dialog> и открывайте его методом showModal() из эффекта React: браузер сам выводит окно в верхний слой, делает страницу под ним инертной, закрывает окно по Esc и возвращает фокус элементу, который был в фокусе до открытия. Компоненту остаётся синхронизировать состояние через событие close, закрывать окно по клику на подложку, подписать его через aria-labelledby и заблокировать прокрутку одним CSS-правилом. Ниже — готовый Modal.tsx, стили с учётом prefers-reduced-motion и тесты в настоящем браузере.
После материала вы сможете
- подключить компонент
Modalна нативном<dialog>: Esc, клик по подложке, блокировка прокрутки и возврат фокуса - подписать окно через
aria-labelledbyиaria-describedbyс идентификаторами изuseId - проверить окно тестами Vitest в браузерном режиме и сценариями Playwright с axe
- собрать на основе компонента окно подтверждения с фокусом на безопасном действии
Что знать заранее
Проверено на
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)
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача и требования к окну
Задача: окно записи, подтверждения или короткой формы, которым одинаково удобно пользоваться мышью, клавиатурой и со скринридером. Требования берём из шаблона Dialog (Modal) в WAI-ARIA Authoring Practices (APG): страница под окном недоступна, при открытии фокус переходит внутрь, Esc закрывает окно, после закрытия фокус возвращается к кнопке, которая окно открыла, у окна есть подпись и видимая кнопка закрытия.
Страница под окном недоступна для мыши, клавиатуры и скринридера
- Кто обеспечивает в рецепте
- Браузер:
showModal()делает остальной документ инертным, как атрибутinert
Фокус при открытии — внутри окна
- Кто обеспечивает в рецепте
- Браузер фокусирует первый фокусируемый элемент; компонент — элемент с
data-autofocus
Esc закрывает окно
- Кто обеспечивает в рецепте
- Браузер: запрос на закрытие, затем события
cancelиclose
После закрытия фокус — на прежнем месте
- Кто обеспечивает в рецепте
- Браузер: по стандарту HTML
close()возвращает фокус элементу, который был в фокусе доshowModal()
У окна есть имя и описание
- Кто обеспечивает в рецепте
- Компонент:
aria-labelledbyна заголовок,aria-describedbyна текст-пояснение
Клик по подложке закрывает окно
- Кто обеспечивает в рецепте
- Компонент: обработчики
pointerdownиclickна<dialog>
Страница под окном не прокручивается
- Кто обеспечивает в рецепте
- CSS:
:root:has(.modal:modal) { overflow: hidden }
Анимация не мешает тем, кто просил меньше движения
- Кто обеспечивает в рецепте
- CSS: переходы только внутри
prefers-reduced-motion: no-preference
Компонент: Modal.tsx и стили
Компонент управляемый: открытием владеет родитель через проп open, а о любом закрытии узнаёт из onClose. Окно открывается в эффекте — это синхронизация React с внешней системой, 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 = 'Закрыть',
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() // верхний слой, ::backdrop, страница под окном становится inert
dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus()
// close() возвращает фокус элементу, который был в фокусе до showModal()
return () => dialog.close()
}, [open])
return (
<dialog
ref={dialogRef}
className="modal"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
// любое закрытие (Esc, кнопка, подложка, form method="dialog") приходит сюда
onClose={onClose}
onPointerDown={(event) => {
pressedOnBackdrop.current = event.target === event.currentTarget
}}
onClick={(event) => {
// клик по подложке: и нажатие, и отпускание — вне содержимого окна
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()вызывается в эффекте, функция очистки вызываетclose(): окно закрывается и приopen = false, и при размонтировании. Повторныйclose()на закрытом окне ничего не делает, поэтому двойной запуск эффекта в StrictMode безопасен.- Событие
closeприходит при любом закрытии — Esc, кнопка «×», подложка, отправка<form method="dialog">. ОбработчикonCloseвозвращает состояние родителя вfalse, и React не расходится с браузером. - Клик по подложке браузер адресует самому
<dialog>. У окна нулевые отступы, всё содержимое лежит во внутреннем.modal__body, поэтомуevent.target === event.currentTargetозначает «мимо окна». - Проверка
pointerdownзащищает от жеста «выделил текст в поле и отпустил кнопку мыши над подложкой». Без неё в наших тестах окно в этом случае закрывалось в Chromium и WebKit. useIdвыдаёт уникальные идентификаторы дляaria-labelledbyиaria-describedby, так что несколько окон на странице не конфликтуют.
.modal {
width: min(32rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
padding: 0; /* отступы — у .modal__body, чтобы клик по самому <dialog> означал подложку */
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;
}
/* блокировка прокрутки страницы, пока открыто модальное окно */
:root:has(.modal:modal) {
overflow: hidden;
}
/* анимация — только если пользователь не просил уменьшить движение */
@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);
}
}
}Анимация сделана на CSS-переходах. @starting-style задаёт начальное состояние при появлении, а display и overlay с allow-discrete оставляют окно на экране до конца перехода при закрытии. Свойство overlay, по данным MDN, работает пока не во всех браузерах — там окно просто исчезнет без анимации закрытия. Весь блок вложен в @media (prefers-reduced-motion: no-preference): если в системе включено «уменьшить движение», окно появляется мгновенно.
Как подключить
Скопируйте два файла
Modal.tsxиModal.css— вsrc/components/. Порталы иz-indexне нужны: модальное окно выводится в верхний слой поверх всей страницы.Храните состояние у родителя
const [open, setOpen] = useState(false), в окно передайтеopenиonClose={() => setOpen(false)}.Отметьте начальный фокус
Атрибут
data-autofocus— на поле или кнопке, с которой человек начнёт. Без него браузер сфокусирует первый фокусируемый элемент, то есть кнопку «×».Закрывайте окно после действия
После успешной отправки формы вызовите
setOpen(false): эффект вызоветclose(), фокус вернётся на кнопку «Записаться».
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'))
// здесь — отправка заявки на сервер
setStatus(`Заявка принята, перезвоним на ${phone}`)
setOpen(false)
}
return (
<main>
<h1>Салон красоты</h1>
<button type="button" onClick={() => setOpen(true)}>
Записаться
</button>
<p role="status">{status}</p>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Запись на приём"
description="Оставьте телефон — администратор перезвонит и подберёт время."
>
<form onSubmit={handleSubmit}>
<label htmlFor="phone">Телефон</label>
<input id="phone" name="phone" type="tel" autoComplete="tel" required data-autofocus />
<button type="submit">Отправить</button>
</form>
</Modal>
</main>
)
}Как проверить результат
jsdom — привычная среда тестов Vitest — в версии 30.1.2 не реализует showModal() и close(): задача «Implement HTMLDialogElement» в трекере jsdom открыта. Поэтому компонент тестируем в браузерном режиме Vitest: тот же Testing Library, но в настоящем Chromium через Playwright, с реальной клавиатурой и фокусом.
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)}>
Записаться
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Запись на приём"
description="Оставьте телефон — администратор перезвонит."
>
<label htmlFor="phone">Телефон</label>
<input id="phone" type="tel" data-autofocus />
</Modal>
</main>
)
}
afterEach(cleanup)
async function openModal() {
render(<Demo />)
const opener = screen.getByRole('button', { name: 'Записаться' })
opener.focus() // открываем с клавиатуры: так проверяется возврат фокуса
await userEvent.keyboard('{Enter}')
const dialog = screen.getByRole<HTMLDialogElement>('dialog', { name: 'Запись на приём' })
return { opener, dialog }
}
test('открывается модально, с подписью, описанием и фокусом внутри', async () => {
const { dialog } = await openModal()
expect(dialog.matches(':modal')).toBe(true)
expect(dialog).toHaveAccessibleDescription('Оставьте телефон — администратор перезвонит.')
expect(document.activeElement).toBe(screen.getByLabelText('Телефон'))
expect(getComputedStyle(document.documentElement).overflow).toBe('hidden')
})
test('Esc закрывает окно и возвращает фокус на кнопку', 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 синхронизировано: окно открывается повторно
await userEvent.keyboard('{Enter}')
expect(dialog.open).toBe(true)
})
test('кнопка «Закрыть» закрывает окно', async () => {
const { opener, dialog } = await openModal()
await userEvent.click(screen.getByRole('button', { name: 'Закрыть' }))
await expect.poll(() => dialog.open).toBe(false)
expect(document.activeElement).toBe(opener)
})
test('клик по подложке закрывает, клик внутри — нет', async () => {
const { dialog } = await openModal()
await userEvent.click(screen.getByText('Оставьте телефон — администратор перезвонит.'))
expect(dialog.open).toBe(true)
// точка в 10 px левее и выше окна — это ::backdrop
await userEvent.click(page.elementLocator(dialog), { position: { x: -10, y: -10 } })
await expect.poll(() => dialog.open).toBe(false)
})
test('axe: нарушений доступности в открытом окне нет', async () => {
const { dialog } = await openModal()
// ждём конца анимации появления: на полупрозрачном тексте axe видит плохой контраст
await Promise.all(dialog.getAnimations({ subtree: true }).map((animation) => animation.finished))
const results = await axe.run(dialog)
expect(results.violations).toEqual([])
})Тесты действительно ловят поломки: мы по очереди убирали onClose, проверку подложки, data-autofocus, aria-describedby, правило блокировки прокрутки, меняли showModal() на show() — каждый раз падал хотя бы один тест. Ожидание конца анимации перед axe не случайно: без него axe иногда застаёт полупрозрачный текст и сообщает о недостаточном контрасте.
Сценарии целиком — клавиатура, прокрутка колесом, prefers-reduced-motion, отправка формы и axe по всей странице — проверяем end-to-end в Playwright сразу в трёх движках. Dev-сервер Vite рендерит приложение в StrictMode, так что двойной запуск эффекта тоже проверен.
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:5180' },
webServer: { command: 'npm run dev -- --port 5180 --strictPort', url: 'http://localhost:5180' },
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('клавиатура: фокус внутрь, Tab не уходит на страницу, Esc возвращает фокус', async ({ page }) => {
const opener = page.getByRole('button', { name: 'Записаться' })
await opener.focus()
await page.keyboard.press('Enter')
const dialog = page.getByRole('dialog', { name: 'Запись на приём' })
await expect(dialog).toBeVisible()
await expect(page.getByLabel('Телефон')).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('прокрутка страницы заблокирована, пока окно открыто', async ({ page }) => {
await page.evaluate(() => (document.body.style.minHeight = '300vh'))
await page.getByRole('button', { name: 'Записаться' }).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 отключает переход', 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('форма: отправка закрывает окно и возвращает фокус', async ({ page }) => {
const opener = page.getByRole('button', { name: 'Записаться' })
await opener.focus()
await page.keyboard.press('Enter')
await page.getByLabel('Телефон').fill('+7 700 000 00 00')
await page.keyboard.press('Enter')
await expect(page.getByRole('dialog')).toBeHidden()
await expect(page.getByRole('status')).toHaveText('Заявка принята, перезвоним на +7 700 000 00 00')
await expect(opener).toBeFocused()
})
test('axe: страница с открытым окном без нарушений', async ({ page }) => {
await page.getByRole('button', { name: 'Записаться' }).click()
const dialog = page.getByRole('dialog', { name: 'Запись на приём' })
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 # компонентные тесты в Chromium
npx playwright test # сценарии в Chromium, Firefox и WebKit- Tab до «Записаться», Enter — окно открылось, фокус в поле «Телефон»
- Tab по кругу — фокус не попадает на элементы страницы под окном
- Esc — окно закрылось, фокус снова на «Записаться»
- Колесо мыши при открытом окне не прокручивает страницу
- В панели Accessibility браузера у окна роль dialog, имя «Запись на приём» и описание
- С включённым «уменьшить движение» окно появляется без анимации
Варианты: подтверждение и форма
Для шага, который трудно отменить, APG советует ставить начальный фокус на наименее разрушительное действие. Окно подтверждения собирается из того же Modal: фокус — на «Оставить запись», клик по подложке окно не закрывает.
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)}>
Отменить запись
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Отменить запись?"
description="Время освободится, и его сможет занять другой клиент."
closeOnBackdrop={false}
>
{/* фокус — на безопасное действие (рекомендация APG для необратимых шагов) */}
<button type="button" data-autofocus onClick={() => setOpen(false)}>
Оставить запись
</button>
<button
type="button"
onClick={() => {
onConfirm()
setOpen(false)
}}
>
Да, отменить
</button>
</Modal>
</>
)
}- Короткие критичные подтверждения APG описывает отдельным шаблоном Alert Dialog с ролью
alertdialog. Если он нужен, добавьте компоненту пропroleи передавайтеrole="alertdialog". - Длинная форма с несохранёнными данными:
closeOnBackdrop={false}, чтобы случайный клик мимо окна не стёр введённое. Esc оставьте — MDN напоминает, что пользователи клавиатуры его ожидают. - Окно без своей логики отправки:
<form method="dialog">закрывает его при отправке, аvalueнажатой кнопки попадает вreturnValue. С компонентом это работает — закрытие приходит через то же событиеclose, мы проверили это отдельным тестом. - Атрибут
closedby="any"включает закрытие по клику снаружи без JavaScript, но, по данным MDN, пока поддерживается не во всех основных браузерах — поэтому подложка обработана вручную.
Ограничения и на чём основан рецепт
- Tab: до содержимого страницы фокус не доходит — она инертна. Но в Chromium и WebKit после последнего элемента окна фокус на шаг уходит из документа, в интерфейс браузера, и затем возвращается к первому элементу. APG описывает строгое зацикливание внутри окна; если нужно именно оно, понадобится собственная ловушка фокуса.
- Блокировку прокрутки мы проверили в Chromium 156, Firefox 157 и WebKit 27.2 под Playwright. На реальных iOS- и Android-устройствах не проверяли.
- Если кнопка, открывшая окно, исчезает вместе с действием (например, удалена строка таблицы), браузеру некуда вернуть фокус. По APG его нужно перевести на логически следующий элемент — вручную, в
onClose. - Второе окно, открытое поверх первого, по MDN оказывается над ним в верхнем слое. Такой сценарий мы отдельно не тестировали.
- Не открывайте окно атрибутом
openи не ставьтеtabindexна<dialog>: MDN рекомендуетshowModal(), аtabindexна этом элементе запрещает.
Основы, на которых стоит рецепт: эффекты, очистка и StrictMode — в уроке «useEffect: синхронизация с внешним миром и когда он не нужен»; нативные элементы, управление фокусом и ARIA — в «Доступность в коде: семантика, фокус, ARIA»; события pointerdown и click, разница target и currentTarget — в «JavaScript в браузере: DOM и события».
Как проверено5 · пройденосверено 12 октября 2026 г.
- запущено, работает
src/components/Modal.tsx, Modal.css, src/App.tsx
временный проект create vite --template react-ts: npm run build (tsc -b на TypeScript 6.0.3 + vite build 8.3.4) — успешно; tsc -b на TypeScript 7.0.2 — 0 ошибок; oxlint — 0 замечаний
- запущено, работает
vitest.config.ts, src/components/Modal.test.tsx, CancelBookingButton.tsx (+ тест)
Vitest 5.0.3 в браузерном режиме (@vitest/browser-playwright): 18/18 в Chromium 156, Firefox 157, WebKit 27.2 (тесты Modal — 5 прогонов подряд без падений после ожидания анимации перед axe; до него axe нестабильно ловил color-contrast). Мутационная проверка: без onClose, без проверки подложки, без data-autofocus, без aria-describedby, без :root:has, show() вместо showModal(), без closeOnBackdrop — падает хотя бы один тест
- запущено, работает
playwright.config.ts, e2e/modal.spec.ts
Playwright 1.64.0 против dev-сервера Vite (StrictMode): 15/15 в Chromium, Firefox, WebKit; @axe-core/playwright 4.13.0 — 0 нарушений; без правила блокировки прокрутки тест падает (scrollY 600)
- запущено, работает
Утверждения статьи о поведении браузеров
пробные тесты: jsdom 30.1.2 — showModal/close не функции; React 19.3 не выводит атрибут autofocus; возврат фокуса нативный во всех трёх движках (тесты проходят без ручного focus()); в WebKit после клика мышью фокус не на кнопке; Tab: Chromium/WebKit после последнего элемента уходят на body (вне документа) и возвращаются, Firefox headless остаётся на последнем; перетаскивание из поля на подложку без проверки pointerdown закрывает окно в Chromium и WebKit, не в Firefox; form method=dialog закрывает окно, returnValue = value кнопки, состояние синхронизируется
- запущено, работает
EN-версия кода
копия modal-demo-en с переведёнными строками и комментариями: build, tsc -b 7.0.2, oxlint — чисто; Vitest 18/18, Playwright 15/15
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.