Dialog
A modal window layered over the page, built on the native <dialog> element — so focus trapping, Escape-to-close, and top-layer stacking come from the browser.
Usage#
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "neelam-ui";
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Make changes to your profile.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Cancel</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>Why the native element matters#
DialogContent is a real <dialog> opened with showModal(). That single
choice hands four hard problems to the browser:
- Focus trapping —
Tabcannot leave the dialog, with no key interception. - Inert background — content behind is made inert by the platform, so it is removed from the accessibility tree, not merely visually covered.
- Top-layer stacking — the dialog renders above everything regardless of
z-indexor transformed ancestors, which is where hand-rolled modals usually break. Escapeto close — built in, including thecancelevent.
A dialog is one of the few widgets where a native element genuinely beats a
JavaScript implementation. Most focus-trap bugs in the wild come from
re-implementing what showModal() already does correctly.
Destructive confirmation#
Consider AlertDialog instead
For an interruption the user must resolve before continuing — a genuinely
irreversible action — AlertDialog carries
role="alertdialog" and does not close on an outside click, which is the
correct pattern there.
Controlled#
Pass open and onOpenChange to drive the dialog from your own state — for
closing it after an async save completes, for example:
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>…</DialogContent>
</Dialog>onOpenChange fires for every path that changes the state: DialogTrigger,
DialogClose, the built-in close button, Escape, and an outside click.
Driving the dialog from inside#
useDialog gives any descendant access to the open state, so a control that is
not a DialogClose can still close the dialog:
import { Button, useDialog } from "neelam-ui";
function SaveButton() {
const { onOpenChange } = useDialog();
return <Button onClick={() => onOpenChange(false)}>Save changes</Button>;
}This is the recommended way to get a real Button into a dialog footer —
DialogClose renders its own element and only closes.
Keyboard#
| Key | Behaviour |
|---|---|
| EnterSpace | Opens the dialog when focus is on DialogTrigger. |
| Escape | Closes the dialog. Native <dialog> behaviour. |
| TabShift+Tab | Cycles focus within the dialog and cannot leave it. |
Accessibility#
DialogTitleprovides the dialog's accessible name and is required — omit it and screen reader users get an unnamed modal.DialogDescription, when present, is wired to the dialog witharia-describedby.- Focus moves into the dialog on open and returns to the trigger on close.
- Background content is inert via the platform, so it cannot be reached by keyboard or virtual cursor.
- All transitions are dropped under
prefers-reduced-motion.
A title is not optional
If a visible heading does not suit the design, render DialogTitle with
sr-only rather than leaving it out — that is exactly what CommandDialog
does internally.
API reference#
Dialog#
| Prop | Type | Default |
|---|---|---|
children | ReactNode | — |
defaultOpenInitial open state when uncontrolled. Defaults to `false`. | boolean | false |
onOpenChangeCalled whenever the open state changes, whether from `DialogTrigger`, `DialogClose`, the built-in close button, Escape, or an outside click. | ((open: boolean) => void) | — |
openControls the open state. Omit to let the dialog manage its own state. | boolean | — |
DialogContent#
| Prop | Type | Default |
|---|---|---|
closeOnOutsideClickCloses the dialog when the area outside it (the backdrop) is clicked. Defaults to `true`. | boolean | true |
hideCloseButtonHides the built-in close button in the top-right corner. Defaults to `false`. | boolean | false |
onClose | ((event: SyntheticEvent<HTMLDialogElement, Event>) => void) | — |