Dialog
A modal interrupts everything. That cost is only worth paying when the user genuinely cannot continue without deciding — which is far rarer than most products assume.
Also called Modal, Popup, Alert Dialog, Confirm, Lightbox — in this system all of them are Dialog.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Destructive confirmation
Type-to-confirm for anything irreversible. The primary action stays disabled until the name matches, and the button says what it deletes.
Sizes
Small for a decision, medium for a short form, large for content, fullscreen for a genuine sub-application on mobile.
Multi-step
A wizard in a dialog needs a visible position, a Back that works, and no Escape-to-close once the user has entered data — losing three steps of input to a stray keypress is unforgivable.
What to use instead
Most dialogs in a product are one of these three patterns wearing the wrong component.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Small confirmation dialog. Icon, title, one line of consequence, and a footer where the primary action sits closest to the corner the eye exits from.
- Max width24rem (sm) — 32rem (md)
Past about 60rem the eye has too far to travel between the title and the confirm button, and the dialog stops feeling like a single decision.
- Radius20px · --radius-2xl
Larger than a card. A modal is the topmost surface in the system, and the softer corner is part of what places it there.
- Scrim72% dark, 2px blur
The page behind should read as paused, not gone. A fully opaque scrim removes the context that told the user what they are confirming.
- Elevation--shadow-e5
The highest level in the system. Combined with the scrim, it is unambiguous that nothing behind is available.
- Padding24px horizontal
One step above a card, because the dialog is the only thing on screen and can afford the room.
- Action orderCancel then primary
Right-aligned with the primary last, at the corner the eye exits from on desktop. On mobile, stack with the primary on top.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-overlay | — | Panel background |
| --ds-layer-scrim | — | Backdrop |
| --ds-border | — | Panel edge |
| --ds-danger-subtle | — | Destructive icon container |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-x | Header, body and footer | |
| footer gap | Between actions | |
| viewport inset | Minimum margin around the panel |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-2xl | Panel corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e5 | — | Panel elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| scale-in | Panel entrance | |
| fade-in | Scrim entrance |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Max width | When to use |
|---|---|---|---|---|---|
| Small | — | 24px | 20px | 24rem | A yes/no decision with one sentence of context. |
| Medium | — | 24px | 20px | 32rem | The default. A short form or a confirmation with detail. |
| Large | — | 24px | 20px | 44rem | Content — a diff, a preview, a list of affected resources. |
| Extra large | — | 24px | 20px | 60rem | Rare. A picker or an editor that genuinely needs the width. |
| Fullscreen | — | 24px | 0 | 100vw | Mobile, or a sub-application with its own navigation. |
| Max height | min(44rem, 100dvh − 3rem) | — | — | — | Beyond this the body scrolls and the header and footer pin. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The scrim must make the background clearly inactive while leaving it recognisable. 72% in dark and 42% in light are the values we ship.
- The panel border must reach 3:1 against the scrim, or the dialog edge disappears in High Contrast Mode.
- The destructive action must not rely on colour alone. Ours pairs red with an explicit verb and an icon.
Keyboard
| Tab | Cycles inside the dialog only. It never reaches the page behind. |
| Shift + Tab | Cycles backwards, wrapping from the first element to the last. |
| Esc | Closes, unless data would be lost — then it prompts. |
| Enter | Submits the form inside the dialog. Never bound to a destructive default. |
| Focus on open | The first focusable element, or the panel itself if there is none. |
| Focus on close | Returns to the element that opened it. |
Screen readers
- Do not autofocus a destructive button. Focus the first field, the cancel action, or the panel.
- Announce the consequence in the description, not only in the title. "This cannot be undone" is the part that matters.
- A dialog opened by a keyboard shortcut with no visible trigger still needs a focus-restore target — remember what had focus before it opened.
Focus & touch
- Trap on open, restore on close. Both halves matter — restoring focus to the trigger is what lets a keyboard user carry on from where they were rather than at the top of the page.
- On phones, use fullscreen or a bottom sheet rather than a centred dialog. A centred modal wastes the margins and puts the actions out of thumb reach. Body scroll is locked with scrollbar compensation so the page does not shift.
| Attribute | Applied to | Notes |
|---|---|---|
| role="dialog" + aria-modal="true" | The panel | aria-modal tells assistive tech that everything outside is unavailable. |
| aria-labelledby | The panel | Points at the title, so the dialog announces with a name. |
| aria-describedby | The panel | Points at the description, read after the title. |
| role="alertdialog" | Destructive confirmations | Announced more assertively. Use it for anything irreversible. |
| inert | The background | The modern way to make the rest of the page unreachable by focus and by assistive tech in one attribute. |
| aria-busy | The panel while submitting | So the pending state is announced rather than only drawn. |
Example usage
1import { Dialog } from '@/ui/Overlay'23// Confirmation. Proportional friction for something irreversible.4const [open, setOpen] = useState(false)5const [typed, setTyped] = useState('')67<Dialog8 open={open}9 onClose={() => { setOpen(false); setTyped('') }}10 size="sm"11 tone="danger"12 icon={<AlertTriangle size={17} />}13 title={'Delete ' + service.name + '?'}14 description="This removes the service and all deployment history. It cannot be undone."15 footer={16 <>17 <Button variant="text" onClick={() => setOpen(false)}>Cancel</Button>18 <Button19 variant="danger"20 disabled={typed !== service.name}21 loading={deleting}22 onClick={confirmDelete}23 >24 Delete service25 </Button>26 </>27 }28>29 <Field label={'Type ' + service.name + ' to confirm'} htmlFor="confirm">30 <TextInput id="confirm" value={typed} onChange={(e) => setTyped(e.target.value)} />31 </Field>32</Dialog>3334// Guard the exit when data would be lost35function requestClose() {36 if (!dirty) return setOpen(false)37 if (confirm('Discard your changes?')) setOpen(false)38}3940// The native <dialog> element gives you the top layer, the backdrop41// and Escape for free. Its focus trap is still worth verifying.42const ref = useRef<HTMLDialogElement>(null)43useEffect(() => { open ? ref.current?.showModal() : ref.current?.close() }, [open])Framework-free HTML
<!-- Native dialog: top layer, ::backdrop and Escape come free -->
<dialog class="ds-dialog" aria-labelledby="d-title" aria-describedby="d-desc">
<header class="ds-dialog__header">
<span class="ds-dialog__icon" aria-hidden="true"><svg>…</svg></span>
<div>
<h2 class="ds-dialog__title" id="d-title">Delete api-gateway?</h2>
<p class="ds-dialog__desc" id="d-desc">
This removes the service and all deployment history. It cannot be undone.
</p>
</div>
<button class="ds-dialog__close" aria-label="Close dialog">
<svg aria-hidden="true">…</svg>
</button>
</header>
<div class="ds-dialog__body">…</div>
<footer class="ds-dialog__footer">
<button class="ds-btn ds-btn--text">Cancel</button>
<button class="ds-btn ds-btn--danger">Delete service</button>
</footer>
</dialog>
<!-- Destructive confirmations use role="alertdialog" -->
<dialog role="alertdialog" aria-labelledby="d-title">…</dialog>CSS
.ds-dialog {
inline-size: min(32rem, calc(100vw - 2rem));
max-block-size: min(44rem, calc(100dvh - 3rem));
padding: 0;
border: 1px solid var(--ds-border);
border-radius: var(--radius-2xl);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e5);
animation: scale-in 200ms var(--ease-emphasized) both;
}
/* The page behind should read as paused, not gone */
.ds-dialog::backdrop {
background: var(--ds-layer-scrim);
backdrop-filter: blur(2px);
animation: fade-in 160ms var(--ease-standard) both;
}
/* Scrollable: header and footer pin, only the body moves */
.ds-dialog__header { padding: 20px 24px 16px; }
.ds-dialog__body { overflow-y: auto; padding: 20px 24px; }
.ds-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px;
border-block-start: 1px solid var(--ds-border-subtle);
background: var(--ds-surface);
}
/* Mobile: fullscreen beats a centred modal in the margins */
@media (max-width: 640px) {
.ds-dialog {
inline-size: 100vw;
max-block-size: 100dvh;
border-radius: 0;
margin: 0;
}
.ds-dialog__footer { flex-direction: column-reverse; }
.ds-dialog__footer > * { inline-size: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.ds-dialog { animation-name: fade-in; }
}Component API
Dialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controlled. Renders nothing when false. |
| onClose* | () => void | — | Called by the close button, the scrim and Escape. |
| title* | ReactNode | — | Wired to aria-labelledby. A dialog with no name is unusable by screen reader. |
| description | ReactNode | — | Wired to aria-describedby. Put the consequence here. |
| size | 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen' | 'md' | Match the content, not the importance. |
| tone | 'neutral' | 'danger' | 'success' | 'neutral' | Tints the icon container. |
| scrollable | boolean | false | Body scrolls; header and footer pin with dividers. |
| dismissible | boolean | true | false removes the close button and blocks Escape and scrim-click. Use only when data would be lost. |
| footer | ReactNode | — | Actions. Cancel first, primary last. |
Professional tips
- Count the dialogs in your product and ask of each one: what breaks if this just happens with an Undo? Most of them survive the question, and the product gets faster.
- Autofocus the first input in a form dialog, and nothing at all in a destructive one. Enter should never delete something by accident.
- A wizard in a dialog needs a visible step indicator and a working Back. Without both, users abandon at step two because they cannot tell how much is left.
- On mobile, prefer a fullscreen dialog or a bottom sheet. A centred modal on a 375px screen has no margins to spare and puts the buttons out of thumb reach.
Performance
- Render in a portal at the body. Inside a transformed ancestor, position: fixed silently stops being fixed and the dialog is clipped.
- Do not mount the dialog until it opens. A page with twelve dialogs mounted and hidden pays for all twelve on every render.
- Lock body scroll with scrollbar-width compensation, or the page shifts horizontally the moment the dialog opens.
- The native <dialog> element uses the browser top layer, which sidesteps z-index entirely and is measurably cheaper than a portal plus a manual scrim.
Common mistakes
- No focus trap, so Tab walks into a page the user cannot see.
- Not restoring focus on close, dropping the keyboard user back at the top of the document.
- Stacking dialogs, leaving two Escape presses between the user and the page.
- Autofocusing the destructive action, so Enter deletes.
- Forgetting to lock body scroll, so the page behind scrolls under the scrim.
- A dialog with no accessible name, which announces as "dialog" and nothing else.
Real-world recommendations
- Confirmation fatigue is measurable: track how quickly users dismiss each dialog. Anything under about 800ms is being clicked through without reading.
- For destructive actions on shared resources, name the blast radius — "This will affect 3 environments and 12 deployments" — rather than a generic warning.
- A dialog that appears on page load is almost always a mistake. The user has not asked for anything yet, and it is the fastest way to be dismissed unread.
- When a dialog needs to become a page, it usually already should have been. The moment it grows a scrollbar and a second step, move it.