Labeling
DialogContent does not name itself. Naming the dialog is your job, and it is required: a dialog without a name is inaccessible, because screen readers announce it only as "dialog".
Recommended pattern:
- a visible heading with an
id, referenced byaria-labelledbyonDialogContent aria-labelonDialogContentwhen there is no visible headingaria-describedbypointing to the sentence that explains the consequence, so it is announced together with the name
import { useId, useState, type CSSProperties } from 'react';
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
Heading,
Icon,
IconButton,
Text,
} from '@vega-ui/react';Keyboard Behavior
| Key | Action |
|---|---|
| EnterSpace | On the trigger: opens the dialog. The dialog itself takes the focus, or the child with autoFocus. |
| TabShift + Tab | Moves the focus to the next or previous control inside the dialog. The page behind is inert, so the focus does not reach it. |
| Escape | Closes the dialog and returns the focus to the element that opened it. With closedBy='none' it does nothing. |
In nested dialogs Escape closes only the top one.
Focus Behavior
- focus should enter the dialog when it opens
- closing should restore focus to a sensible trigger
- nested dialog stacks should preserve predictable focus restoration
Focus handling comes from the native <dialog>: the rest of the page is inert while the dialog is open, and closing restores focus to the element that was focused before opening. In Safari a mouse click does not focus a button, so after a mouse-opened dialog closes focus returns to the page body; keyboard activation returns it to the trigger.
On open the dialog itself takes focus, so nothing inside is highlighted and screen readers announce its title; Tab moves to the first control. Put autoFocus on a child to focus it instead, for example the first field of a form.
Screen Reader Semantics
- the dialog is exposed as
role=dialogwitharia-modalby the browser and is named by youraria-labelledbyoraria-label - the trigger exposes
aria-haspopup='dialog'andaria-expanded - the body content should make sense as one modal unit
- close controls should remain explicit when dismissal matters;
DialogClosehas no default label, so an icon-only button inside it needs anaria-label; the library ships no built-in strings
Form Semantics
When the dialog contains fields, treat it as a normal form boundary and retest keyboard submit, close, and focus-return behavior in the real flow.
Buttons in a <form method="dialog"> close the dialog through onChangeOpen(false), and the submit button's value becomes the dialog's returnValue, as with a native <dialog>. This also works with closedBy='none', where Escape and the backdrop do not close the dialog.
Accessibility Risks
- opening a dialog without
aria-labelledbyoraria-label - using nested dialogs without retesting focus restoration
- putting long content into a dialog without a usable scroll strategy
- relying only on click-away dismissal for important flows