Skip to content

Dialogs, Modals & Sheets

A UX principle for coding agents. Also covers modals, sheets, drawers, focus trap, dismissal.

A dialog is a layer over the current context, not a teleport away from it. The screen the user was on stays underneath, dimmed but present, so dismissing the dialog feels like returning rather than navigating back. The moment you reach for a dialog, you're making a claim: this task is worth interrupting the user and stealing their focus, but not worth a new screen. That claim is wrong more often than people think.

Before you build one, ask whether the interaction even needs a layer. The strongest version of this discipline lives in mobile-patterns.md — Prefer Layered Compositions Over Full Screens: layer when you can, push to a new screen only for genuinely new contexts. This doc is the desktop-and-up companion to that rule, plus the mechanics every dialog must get right once you've decided it earns its place.

When to Use a Dialog at All

A dialog is a heavy instrument. It blocks (or at least overlays), it traps focus, it demands a decision. Reach for lighter options first:

  • Inline / in-place — if the task can happen where the user already is (an editable field, an expanding row, a popover anchored to the trigger), do that. The user keeps their place and their scroll position.
  • Popover — for short, focused content tied to a trigger: a mini-form, a picker, an info bubble. Quieter than a modal, anchored to its source.
  • A dialog — when the task needs the user's full attention on a self-contained sub-task and the surrounding context should stay visible-but-paused: a confirmation, a focused form, a detail the rest of the page shouldn't scroll away from.
  • A new page — when the user is entering a genuinely new context, the task needs the whole viewport, or the previous screen would be misleading if left visible (see mobile-patterns.md — When a Full Screen Is the Right Choice).

The rule: a dialog is for a sub-task of the current screen that needs focus but not a new context. If it's not a sub-task of this screen, it's probably a page. If it doesn't need focus, it's probably inline.

The word "modal" describes whether the dialog blocks interaction with the rest of the page, not whether it floats in the center.

  • Modal — the background is inert. The user must deal with the dialog (act or dismiss) before doing anything else. Use it when proceeding without a decision would be incoherent: confirmations, blocking errors, a required step. It sets aria-modal="true" and makes the background inert.
  • Non-modal — the dialog floats but the page behind stays interactive. Use it for supporting tools the user dips in and out of while working: a find-and-replace panel, a properties inspector, a chat widget. It must not trap focus or block the page.

Don't make something modal out of habit. A modal that blocks the page for a task the user might want to reference against the page (copying a value, checking a number) fights the user. If they need both at once, it's non-modal or inline.

Drawers & Bottom Sheets

A drawer (side sheet) or bottom sheet is a dialog docked to an edge instead of centered. It keeps more of the parent context visible, which makes it the better default on touch and at narrow widths.

  • Bottom sheet (phone) — slides up from the bottom, within thumb reach. Support detents so it only takes the height it needs (medium → large), and let the user drag between them (see mobile-patterns.md — Bottom sheets, detents).
  • Side sheet (tablet/desktop) — a ~400px panel docked to the trailing edge keeps the master list visible beside it. At tablet width and above, prefer a side sheet or popover over a full-width bottom sheet (see mobile-patterns.md — Tablet & larger breakpoints).
  • Drawer for navigation — the responsive nav drawer is a sibling pattern; its focus-trap and overscroll rules are the same as any dialog (see navigation.md — Responsive Navigation).

Match the dock to the relationship: edge-docked when the parent context matters during the task, centered modal when the task wants the user's undivided attention.

Focus Management

Focus is what makes a dialog usable by keyboard and screen-reader users, and it's the most commonly botched part. The full contract is specified in interactions.md — Focus Management; the dialog-specific obligations:

  • Move focus in on open, to the first interactive element (or the dialog container itself if there's nothing to focus immediately). Don't leave focus stranded on the now-hidden trigger.
  • Trap focus inside a modal dialog. Tab and Shift+Tab cycle within it; they never reach the inert background.
  • Return focus to the triggering element on close, so the keyboard user lands back where they were.
  • Initial focus is not always the first field. For a destructive confirmation, focus the safe option (Cancel), never the destructive one (see Confirmation Dialogs below).

A non-modal dialog does not trap focus, but it still must be reachable in the tab order and dismissable by keyboard.

Dismissal

Give the user several ways out, but make each one safe.

  • Escape closes the dialog. Always wire it for modal dialogs.
  • Backdrop click closes simple, low-stakes dialogs. It's a convenience, not a contract.
  • Explicit close (an × button, a Cancel action) is always present and always has an accessible name (see review-rules.md — Icon-only buttons missing aria-labels).

Backdrop-click dismissal is dangerous when the dialog holds unsaved work. A user who clicks slightly outside a half-filled form and loses everything will not forgive it. For dialogs with unsaved changes, either disable backdrop-dismiss or confirm before discarding (see forms.md — Unsaved Changes Warning). The same applies to Escape on a dirty form: catch it and confirm rather than silently throwing away input.

function onDismiss(reason: "escape" | "backdrop" | "close") {
  if (isDirty) {
    confirmDiscard(); // "Discard changes?" — don't silently lose work
    return;
  }
  close();
}

Scroll & Overscroll

When a dialog is open, the page behind it should not scroll, and scrolling the dialog should not bleed into the page.

  • Lock the background. Prevent the page behind a modal from scrolling while it's open, so the user's place is preserved and the dialog stays put.
  • Contain overscroll. Set overscroll-behavior: contain on the dialog's scroll container so reaching the end of its content doesn't start scrolling the page behind it (see interactions.md — Overscroll Behavior).
.dialog__scroll {
  overscroll-behavior: contain; /* scroll chaining stops at the dialog */
}

For a bottom sheet, overscroll containment is what lets the user drag the sheet's content without the page lurching underneath.

Confirmation Dialogs

A confirmation dialog interrupts to prevent a mistake. It's justified only when the action is destructive or irreversible and Undo isn't a better answer.

  • Prefer Undo over a pre-confirm for anything reversible. Interrupting before a reversible action is more friction than letting it happen and offering Undo after (see notifications.md — Blocking Alerts & Modals, and Toasts). Save the modal for the truly irreversible.
  • Make the safe choice prominent, the destructive one secondary. Don't auto-focus or visually emphasize the destructive button. Cancel is the easy default; the destructive action uses a quieter or clearly-marked treatment (see interactions.md — Confirm Destructive Actions, and ui/hierarchy.md — destructive action hierarchy: the primary action in a destructive flow should be clear, but the safe option stays more prominent).
  • Name the consequence in the copy, not the mechanism. "Delete 3 projects? This can't be undone." beats "Are you sure?" The user needs to know what and whether it's reversible.
  • For high-stakes, irreversible actions, require deliberate input — type-to-confirm. Asking the user to type the resource's name (or "DELETE") defeats reflexive clicking on actions like deleting a production database or closing an account.
// Type-to-confirm gates a high-consequence, irreversible action
<label htmlFor="confirm">Type <strong>{projectName}</strong> to confirm</label>
<input id="confirm" value={typed} onChange={(e) => setTyped(e.target.value)} />
<button disabled={typed !== projectName} onClick={destroy}>
  Delete project
</button>

Type-to-confirm is the one place pre-disabling a submit is right — the requirement is unambiguous and the cost of a misclick is catastrophic, which is the opposite of the everyday form rule in forms.md — Don't Pre-Disable Submit.

Sizing & Responsiveness

A dialog should be as large as its content needs and no larger. The most common sizing mistake is the full-screen takeover on a desktop for a task that needs a fraction of the viewport.

  • Don't full-screen on desktop without reason. A centered dialog sized to its content keeps the context visible around it. Full-screen on desktop is for genuinely immersive tasks, not a two-field form.
  • On phones, a centered modal often becomes a bottom sheet. The same task that's a small centered dialog on desktop is more reachable as a sheet on a phone (see mobile-patterns.md — Tablet & larger breakpoints, in reverse).
  • Constrain width for readability. A dialog full of text still obeys line-length limits (see ui/typography.md — Keep Your Line Length in Check). Don't let a wide dialog stretch prose to unreadable widths.
  • Handle tall content with an internal scroll, keeping the header and action footer pinned so the primary actions are always reachable without scrolling to the bottom.

Stacking

Dialogs that open dialogs are a smell. Each layer steals focus from the last and buries the user deeper, and the focus-return chain gets fragile fast.

  • One modal at a time. If a dialog needs to open another, reconsider the flow — it's usually a sign the first dialog is doing too much, or that the task wants a page with steps instead.
  • A dialog opening a non-modal helper (a color picker popover, a tooltip) is fine; that's not a second blocking layer.
  • If you truly must stack (rare), each layer manages its own focus trap and returns focus to the layer beneath on close, and only the topmost layer is the active modal.

Entrance & Exit Motion

A dialog's motion should connect it to where it came from and stay out of the user's way.

  • Scale from 0.95, never 0. Animating from scale(0) feels unnatural; start at 0.95 or higher (see ui/animation-and-motion.md — Scale Animations).
  • Be origin-aware where it helps. A popover or menu-like dialog should grow from its trigger, not from its own center (see ui/animation-and-motion.md — Transform Origin). A centered modal can fade-and-scale in place.
  • Keep it short and interruptible. 200–300ms, ease-out on entry. The user must be able to dismiss mid-animation without waiting it out (see ui/animation-and-motion.md — Interruptible Animations).
  • Respect reduced motion. The dialog still appears and disappears; the transition just collapses to near-instant (see ui/animation-and-motion.md — the prefers-reduced-motion override).
.dialog {
  transform-origin: var(--transform-origin); /* origin-aware for anchored dialogs */
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1),
              opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.dialog[data-state="closed"] { transform: scale(0.95); opacity: 0; } /* not scale(0) */
.dialog[data-state="open"]   { transform: scale(1);    opacity: 1; }

Accessibility

A dialog is one of the highest-stakes accessibility surfaces because it changes what "the page" means. Get the roles and focus right or it's unusable without a mouse.

  • Use a real dialog primitive (<dialog> or a vetted library like Radix) so roles, focus trap, and Escape come built-in. Hand-rolled dialogs miss something every time.
  • A native <dialog> opened with showModal() already applies the dialog role, modality, and top-layer inertness for the rest of the document. Don't layer aria-modal="true" or a manual inert on top of it: doubling up can suppress correct content announcement in some screen readers.
  • When you hand-roll or use a library that renders a generic element, set role="dialog" (or alertdialog for confirmations that must be acknowledged) with aria-modal="true" on modal dialogs, and make the background inert so assistive tech and the tab order skip it entirely while it's open.
  • Label the dialog: aria-labelledby pointing at its title, and aria-describedby at its body when there's explanatory text.
  • Every focusable control inside meets the focus-visible and hit-target rules (see interactions.md, review-rules.md).

Checklist

Decision

  • Lighter option ruled out first (inline / popover / page)
  • Dialog is a sub-task of the current screen, not a new context
  • Modal only when proceeding without a decision is incoherent
  • Edge-docked (sheet/drawer) when parent context matters during the task

Focus & dismissal

  • Focus moves in on open, traps inside (modal), returns to trigger on close
  • Destructive confirmations focus the safe option, not the destructive one
  • Escape closes; backdrop-click closes only low-stakes dialogs
  • Unsaved work confirmed before discard (Escape and backdrop)
  • Explicit close control with an accessible name

Scroll & sizing

  • Background scroll locked while a modal is open
  • overscroll-behavior: contain on the dialog scroll container
  • Sized to content; no needless full-screen on desktop
  • Tall content scrolls internally with pinned header/footer actions

Confirmation

  • Undo preferred over pre-confirm for reversible actions
  • Consequence named in copy ("Delete 3 projects? Can't be undone")
  • Type-to-confirm for high-stakes irreversible actions

Motion & a11y

  • Scale from 0.95 (never 0), origin-aware where it helps, interruptible
  • Reduced motion collapses the transition, not the appearance
  • Real dialog primitive; native <dialog> gives role+modality+inertness, else add role/aria-modal/inert yourself
  • Labeled via aria-labelledby
  • One modal at a time (no stacked blocking layers)

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ux-principle({ topic: "dialogs" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems