Dialog
StableA modal on the native <dialog> element: the page behind goes inert, Esc closes it, and focus goes back to what opened it.
Give it a title, an optional description, a body and a footer of buttons. The browser does the hard parts (inert page, top layer, focus return); the component starts focus on the element marked data-autofocus or the first field, scrolls a long body between a fixed header and footer, and closes on a backdrop click unless dismissible is false.
Form in a dialog
import { useId, useRef, useState, type FormEvent } from 'react'import { Button, Dialog, Field, RadioGroup, TextInput, useToast } from '@rchursin/product/ui'
type Visibility = 'private' | 'team'type Project = { name: string; visibility: Visibility }
/** * A form in a dialog. Focus starts on the first field; Esc, the Close button or a click on the * backdrop closes it, and focus goes back to "New project". The footer's submit button reaches * the form through its id. The draft lives outside the dialog, so closing never loses it. */export default function DialogForm() { const [projects, setProjects] = useState<Project[]>([ { name: 'Nightly evals', visibility: 'team' }, { name: 'Docs agent', visibility: 'private' }, ]) const [open, setOpen] = useState(false) const [name, setName] = useState('') const [visibility, setVisibility] = useState<Visibility>('private') const [error, setError] = useState<string>()Usage
import { Dialog } from '@rchursin/product/ui'<Dialog open={open} onClose={() => setOpen(false)} title="New project" footer={<Button type="submit" form="new-project">Create project</Button>}> <form id="new-project" onSubmit={create}>…</form></Dialog>Examples
Destructive confirmation
data-autofocus starts focus on Cancel, so a stray Enter never deletes. After deleting, focus moves to the next thing to do.
import { useRef, useState } from 'react'import { Button, Dialog } from '@rchursin/product/ui'
/** * A destructive confirmation. data-autofocus puts focus on Cancel, the safe choice, so a stray * Enter never deletes anything. After the delete, focus moves to the next thing to do. */export default function DialogConfirm() { const [open, setOpen] = useState(false) const [deleted, setDeleted] = useState(false) const restoreRef = useRef<HTMLButtonElement>(null)
function remove() { setOpen(false) setDeleted(true) // The button that opened the dialog is gone; give focus a sensible place to land window.requestAnimationFrame(() => restoreRef.current?.focus()) }
return (Not dismissible
A click on the backdrop does nothing. Esc, Close and Cancel still close it, and the draft stays in state for next time.
import { useRef, useState } from 'react'import { Button, Dialog, Field, Textarea } from '@rchursin/product/ui'
/** * dismissible={false} for writing: a stray click on the backdrop no longer closes it. Esc, Close * and Cancel still call onClose, and the text stays in state, so it is there when you come back. */export default function DialogNonDismissible() { const [open, setOpen] = useState(false) const [draft, setDraft] = useState('') const [error, setError] = useState<string>() const [published, setPublished] = useState<string | null>(null) const fieldRef = useRef<HTMLTextAreaElement>(null)
const words = draft.trim() ? draft.trim().split(/\s+/).length : 0
function publish() { if (!draft.trim()) { setError('Write the note before publishing it') fieldRef.current?.focus()API
Dialog props
| Prop | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | – | true opens it with showModal(); false closes it. The dialog never changes it itself: closing always goes through onClose. |
onCloserequired | () => void | – | Called on Esc, on the Close button and on a backdrop click (when dismissible). Set open to false here. |
titlerequired | ReactNode | – | The heading (an h2) and the dialog’s accessible name. |
description | ReactNode | – | A line under the title, linked with aria-describedby. |
children | ReactNode | – | The body. It scrolls on its own when the dialog is taller than the window. |
footer | ReactNode | – | Buttons along the bottom, right-aligned above a hairline. A submit button here reaches a form in the body with form="…". |
size | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Width 24, 32, 40 or 52 rem, and never wider than the window minus 2 rem. |
dismissible | boolean | true | false stops a backdrop click from closing it. Esc and the Close button still call onClose. |
hideTitle | boolean | false | Keeps the title and description for screen readers but doesn’t show them. The Close button stays, alone in the top right corner. |
className | string | – | Classes on the card inside the <dialog>. |
CSS
| Class or variable | What it does |
|---|---|
rc-dialog | Resets the native <dialog> (no border, padding or background) and draws the dimmed, blurred ::backdrop. |
rc-dialog motion | With motion allowed, the dialog and backdrop fade in while the dialog scales up from 98%, and reverse on close. Reduced motion: no transition. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| TabShiftTab | Moves through the dialog’s controls. Past the last one, focus visits the browser’s own toolbar and comes back to the first; the page behind is inert, so it never lands there. |
| Esc | Calls onClose, whatever dismissible says. |
| Enter | In a field, submits the form, including through a footer button linked with form="…". |
- It is a native modal <dialog> opened with showModal(): the browser puts it in the top layer and makes everything behind it inert, for the mouse, the keyboard and screen readers.
- It is named by its title (aria-labelledby) and described by the description when there is one (aria-describedby).
- On open, focus goes to the element marked data-autofocus, else the first enabled input, textarea or select in the body. With neither, the browser focuses the first control, which is the Close button.
- On close, the browser hands focus back to the element that had it before, usually the button that opened it. If that button is gone (a deleted row), move focus yourself, as the confirmation example does.
- The Close button is an icon button named “Close”.
Guidelines
Do
- Title it with what happens: “New project”, “Delete ‘Nightly evals’?”.
- Start focus on Cancel in a destructive confirmation (data-autofocus).
- Keep drafts in state outside the dialog, so closing never loses typing.
- Use dismissible={false} for anything people write in.
Don’t
- Don’t use a dialog for a quick choice next to its trigger; use a popover or a menu.
- Don’t open a dialog from inside another dialog.
- Don’t use window.confirm; it can’t be styled and it blocks the page.
In use
Used on this site
Related
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
- Inline confirmA question asked in place of the buttons, never with window.confirm: “Abandon this session?” with Keep going first.
- Create taskThe New task dialog, with a draft that survives Esc, and the dialog that lists every Tasks shortcut.
- Event dialogThe create and edit form for an event, in a dialog, and the details popover that leads to it.
- FieldLabel above, hint under it, error in words between the hint and the control, with the ids and aria wiring done for you.
Source
The component’s own files, exactly as they run on this site.
/** * Modal dialog on the native <dialog> element: the browser traps focus, makes the page behind * inert, closes on Esc and hands focus back to whatever opened it. * * <Dialog open={open} onClose={() => setOpen(false)} title="New task" footer={…}>…</Dialog> * * Focus starts on the first field (put data-autofocus on another element to start there). A click on the dimmed * backdrop closes it unless `dismissible={false}` (use that when closing would lose typing). */import { useEffect, useId, useRef, type ReactNode } from "react"import { cx } from "../../components/cx"import { IconButton } from "./button"
const widths = { sm: "w-[24rem]", md: "w-[32rem]", lg: "w-[40rem]", xl: "w-[52rem]" } as const
export function Dialog({ open, onClose, title, description,