Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

sm24remA yes/no decision. One sentence of context.
md32remThe default. A short form or a confirmation with detail.
lg44remContent: a diff, a preview, a table of affected resources.
xl60remRare. A picker or an editor that needs the width.
fullscreen100vwMobile, or a genuine sub-application.

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.

Reversible action

Do it, then offer Undo in a toast

See the pattern

Long form

A page or a drawer, so the context stays visible

See the pattern

Information only

An alert in the flow of the page

See the pattern

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.

Closed
Scrim
Panel
200ms emphasized
Enteringscale 0.96 → 1
Danger
Success
body scrolls
ScrollableHeader and footer pinned
no ×, no Esc
Non-dismissible
Confirm disabled
Submitting
Tab cycles inside
Focus trapped
focus → trigger
Restored

Anatomy

Every part, every measurement, and the reason it is that number.

Delete api-gateway?

This cannot be undone.

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.

  1. 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.

  2. 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.

  3. 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.

  4. Elevation--shadow-e5

    The highest level in the system. Combined with the scrim, it is unambiguous that nothing behind is available.

  5. Padding24px horizontal

    One step above a card, because the dialog is the only thing on screen and can afford the room.

  6. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-surface-overlay—Panel background
--ds-layer-scrim—Backdrop
--ds-border—Panel edge
--ds-danger-subtle—Destructive icon container

Spacing

TokenValueUsed for
padding-xHeader, body and footer
footer gapBetween actions
viewport insetMinimum margin around the panel

Radius

TokenValueUsed for
--radius-2xlPanel corners

Shadow

TokenValueUsed for
--shadow-e5—Panel elevation

Motion

TokenValueUsed for
scale-inPanel entrance
fade-inScrim entrance

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusMax widthWhen to use
Small—24px20px24remA yes/no decision with one sentence of context.
Medium—24px20px32remThe default. A short form or a confirmation with detail.
Large—24px20px44remContent — a diff, a preview, a list of affected resources.
Extra large—24px20px60remRare. A picker or an editor that genuinely needs the width.
Fullscreen—24px0100vwMobile, or a sub-application with its own navigation.
Max heightmin(44rem, 100dvh − 3rem)———Beyond this the body scrolls and the header and footer pin.

Do

Label the button with the action"Delete service" tells the user what happens. "OK" forces them to re-read the dialog to reconstruct what they are agreeing to.
Make the confirmation proportional to the riskA second click becomes muscle memory within a week. Typing the resource name is a decision, and it is the standard for anything that destroys production data.
Header
Scrolling content that goes on for a while and keeps going past the visible area of the dialog body.
Pin the header and footer when the body scrollsA dialog whose action buttons scroll off the bottom is a dialog people abandon. The decision must always be reachable.
dirty form → confirm before closing · clean form → Escape closes
Block dismissal only when data would be lostEscape and scrim-click should almost always work. Turning them off is justified when the user has entered data, and almost never otherwise.

Don't

Save changes?

Do not confirm reversible actionsEvery unnecessary confirmation teaches the user to click through dialogs without reading, which is exactly what makes the important one fail.

First dialog

Second dialog

Do not stack dialogsA dialog opening a dialog leaves the user with two Escape presses to escape and no idea which surface owns which action. Replace the content instead.
…7 more fields below
Do not put a long form in a dialogA twelve-field form in a modal is a page with the context removed and a scrollbar added. Users cannot reference the data behind the scrim, and mobile makes it worse.
Do not make the destructive action the defaultDo not autofocus Delete. A user pressing Enter out of habit should not destroy anything — focus the cancel action, or nothing.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

2.1.2No Keyboard TrapA2.4.3Focus OrderA2.4.11Focus Not ObscuredAA3.3.4Error PreventionAA4.1.2Name, Role, ValueA

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

TabCycles inside the dialog only. It never reaches the page behind.
Shift + TabCycles backwards, wrapping from the first element to the last.
EscCloses, unless data would be lost — then it prompts.
EnterSubmits the form inside the dialog. Never bound to a destructive default.
Focus on openThe first focusable element, or the panel itself if there is none.
Focus on closeReturns 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.
AttributeApplied toNotes
role="dialog" + aria-modal="true"The panelaria-modal tells assistive tech that everything outside is unavailable.
aria-labelledbyThe panelPoints at the title, so the dialog announces with a name.
aria-describedbyThe panelPoints at the description, read after the title.
role="alertdialog"Destructive confirmationsAnnounced more assertively. Use it for anything irreversible.
inertThe backgroundThe modern way to make the rest of the page unreachable by focus and by assistive tech in one attribute.
aria-busyThe panel while submittingSo the pending state is announced rather than only drawn.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
descriptionReactNode—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.
scrollablebooleanfalseBody scrolls; header and footer pin with dividers.
dismissiblebooleantruefalse removes the close button and blocks Escape and scrim-click. Use only when data would be lost.
footerReactNode—Actions. Cancel first, primary last.

Notes

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.