Design a modal dialog
A reusable modal dialog for a design system: a confirm box, a sign-in form, a settings sheet. It looks like a box on a dark overlay, but the hard parts are moving focus in and back out, making the rest of the page unreachable, clear closing rules, scroll locking, stacking, animation, phones, and the URL.
The question
Design a modal dialog component for our design system. Product teams will use it for small confirm boxes ("Delete this file?"), for a sign-in form, and for a big settings panel with many fields.
Tell me what the API looks like, how it is built, and how it behaves for keyboard users, screen reader users, and people on phones. What does the browser give us today, and what do we still have to build ourselves?
Real-life examples: GitHub's "Delete this repository" confirm, Gmail's keyboard shortcuts dialog, The sign-in modal on Medium, Slack's preferences window, Instagram's post viewer over the feed
Requirements: what to ask before drawing anything
A modal dialog is a box that opens on top of the page and asks for the user's full attention. While it is open, the page behind it is dimmed and can't be used. The user must finish with the dialog, or close it, before going back. "Modal" means exactly this: the app switches into a mode where only the dialog matters.
It sounds like the simplest component in a design system. A box, a dark overlay, a close button. That's why it's a popular easy question: the visible part takes five minutes, and the interviewer wants to see if you know the invisible part.
Where does keyboard focus go when it opens? Can a screen reader user wander out of it? What happens when someone presses Escape in the middle of typing a long message? Why does the page jump sideways when it opens?
Start by asking a few questions. Each answer below changes something in the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| What kinds of dialogs? | Three: a small confirm ("Delete 3 files?"), a medium form (sign in), and a large settings panel with 20 fields and tabs. | Sizes, a scrolling body, and closing rules that differ: a confirm can close on any click outside, a half-filled form must not. |
| Who uses the component? | About 40 product teams in one React app. They will open it from buttons, menus, keyboard shortcuts, and sometimes from code after a request fails. | The API must work both when a team owns the open state and when it doesn't care. |
| Which browsers and devices? | The last two versions of Chrome, Edge, Firefox, and Safari, plus iOS Safari and Chrome on Android. About 55% of sessions are on phones. | We can use the native dialog element. On phones the big dialogs become full-screen sheets. |
| Accessibility target? | WCAG 2.2 level AA, following the WAI-ARIA Authoring Practices dialog pattern. | Focus moves in and back, the background is hidden from screen readers, and the dialog has a name. |
| Can a dialog open another dialog? | Yes, sometimes: "Discard changes?" on top of the settings panel. | Stacking: the newest dialog must sit on top and Escape must close only that one. |
| Should a dialog have its own URL? | Some do: a shared link to a photo or to the settings panel should open it. | Routing and what the browser back button does. |
| How big can the code be? | Under 4 KB compressed for the core, because every page loads it. | No heavy animation or focus libraries if the browser can do the job. |
Functional requirements
- Open and close a dialog from a button, from a keyboard shortcut, or from code.
- Show a title, an optional description, a body that can scroll, and a footer with action buttons.
- Offer a few sizes (small, medium, large) and a full-screen sheet on small screens.
- Close with a close button, with the Escape key, and, when allowed, with a click on the dark overlay. Tell the caller which of these happened.
- Ask "Discard changes?" before closing a form that has unsaved edits.
- Let a dialog open a second dialog on top of it.
- Let some dialogs be opened from a link, and close them with the browser back button.
Non-functional requirements, with numbers
- Accessible. Keyboard focus moves into the dialog when it opens and returns to the button that opened it when it closes. Tab never reaches the page behind. Screen readers announce the dialog's title and role.
- Stable layout. Opening a dialog must not move the page behind it by even 1 pixel (no jump when the scrollbar disappears).
- Fast. The dialog paints within one frame of the click, so the click's INP (Interaction to Next Paint, how long until the screen reacts) stays well under 200 ms. The open animation lasts 150 to 200 ms, and none at all for people who ask for reduced motion.
- Small. Under 4 KB of compressed JavaScript for the core component.
- Works on phones. Inputs stay visible above the on-screen keyboard, and nothing hides behind the browser's address bar.
Out of scope, and why
- Popovers, menus, and tooltips. They float on top too, but they don't block the page. They are a different pattern with different rules (the popover attribute).
- Toasts. Short messages that appear and leave on their own. They must never steal focus.
- Drawers that slide from the side and keep the page usable. Those are non-modal. We only cover the modal case, but the same component can render as a side sheet.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design of the component itself? Select all that apply.
What makes a dialog harder than it looks
Most components only care about the space they draw in. A button changes itself when you press it. A modal dialog is different: when it opens, it changes the whole page. That is the root of every hard part below.
| What is true about a modal | What it forces us to design |
|---|---|
| It takes over the keyboard. | Focus must jump into the dialog, stay inside while it's open, and land back on the right button afterwards. |
| It hides the rest of the page. | Mouse clicks, the Tab key, and screen readers must all stop reaching the page behind. Dimming it with a gray overlay only hides it from the eyes. |
| It must always be easy to leave. | Escape, a close button, and maybe a click outside. Yet a half-written form must not vanish because of one stray click. |
| It sits on top of everything. | It must cover sticky headers, chat widgets, and other dialogs, even when some parent has a CSS transform or overflow hidden. |
| It freezes the page under it. | The page behind must not scroll, and must not jump sideways when its scrollbar goes away. |
| It shows up on every screen size. | A 400 px wide box works on a laptop. On a 375 px phone with the keyboard open, it needs to become a full-screen sheet. |
| Its open state is sometimes part of the address. | A link can open it, and the back button should close it rather than leave the page. |
Ten years ago, every one of these rows meant custom JavaScript. Libraries such as Bootstrap's modal, React Modal, and Reach UI shipped hundreds of lines just to fake what a browser now does for free. The biggest decision in this design is therefore the first one: how much of the job do we hand to the browser's own dialog element?
Think first
A teammate says: "A modal is just a div with position fixed and a high z-index." Before reading on, list three things that div gets wrong for a keyboard user.
Focus stays on the button behind the overlay, so the next Tab moves through the page, not the dialog.
Tab can walk out of the dialog into links under the gray overlay, which the user can't even see.
Escape does nothing, and when the box closes, focus is lost to the top of the page instead of returning to the button.
Decision 1: the native dialog element or a custom div
Let's start with the naive build: a div with position: fixed, a dark overlay behind it, and z-index: 9999. Mount it wherever the button is. It works on the first demo page. Then it meets a real app.
- A team opens it from inside a product card that has
transform: scale(1.02)on hover. A transform on any parent makesposition: fixedstick to that parent, not to the screen. The "full-screen" overlay is now 300 px wide and clipped. - The page has a sticky header with
z-index: 1000inside a parent that creates its own stacking context (a separate layer group that z-index can't escape). The header draws over the dialog, even though 9999 is bigger. - A support chat widget uses
z-index: 2147483647, the largest allowed value. It sits on top of every dialog forever.
Old libraries fixed this with a portal: render the dialog as the last child of body, far from any transformed parent. Then they added a focus trap, an Escape listener, aria-hidden on every other part of the page, and a scroll lock. That's the custom approach. Today we have a second choice.
Decision What should the dialog be built on?
What we pick: Use the native dialog with showModal(). Every browser on our list has supported it since 2022, and it solves the hardest problems (stacking, inert background, Escape) in a way no JavaScript can fully match. We write a thin layer for the gaps instead of a whole engine.
What showModal() gives you for free
| Feature | What it does | Why it matters |
|---|---|---|
| The top layer | The open dialog is drawn in a special layer above the whole page, outside every stacking context. | No z-index, no portal. A parent's transform or overflow can't clip it. Even the chat widget sits below it. |
::backdrop | A pseudo-element (a box CSS creates for you) that covers the screen right behind the dialog. | The dark overlay is one CSS rule, for example a background of black at 50% opacity. |
| Inert background | Everything outside the dialog becomes inert: it can't be clicked, focused, or read by screen readers. | Tab and screen reader cursors stay inside. No aria-hidden on siblings. |
Escape and the cancel event | Escape fires a cancel event on the dialog, then closes it. In recent Chrome on Android, the back gesture does the same. | Closing is built in, and we can listen to cancel to ask about unsaved work. |
| Focus on open | Focus goes to the element with the autofocus attribute inside, or else the first focusable element, or else the dialog itself. | Keyboard users start inside the dialog. |
| Focus on close | Focus goes back to the element that had it before the dialog opened. | The user lands on the button they pressed. |
form method="dialog" | Submitting a form inside closes the dialog and stores the clicked button's value in dialog.returnValue. | A confirm box needs no JavaScript to know if the user chose "delete" or "cancel". |
closedby | A newer attribute: any also closes on a click outside, closerequest closes only on Escape (or Android back) or code, none only on code. A modal dialog without it acts like closerequest. | Light dismiss (closing by clicking outside) without hand-written pointer code, where supported. |
showModal() does all of it. The right column is small, and each item has its own chapter.The gaps we still fill
- Scroll lock. The page behind can still scroll with the mouse wheel. We lock it ourselves (chapter 8).
- Light dismiss in every browser. Until
closedby="any"is everywhere, we handle clicks on the backdrop by hand, carefully (chapter 7). - Tab wrapping. After the last button, Tab moves into the browser's own toolbar and address bar, then back to the first button. That is allowed and many experts prefer it, but it differs from the wrap-around that the APG examples show (chapter 5).
- A trigger that's gone. If the button that opened the dialog was removed (a deleted row), focus falls to the top of the page. We pick a better target (chapter 5).
- Unsaved changes. The browser doesn't know your form is dirty. We add a guard (chapter 7).
- Exit animations. A closing dialog vanishes at once unless we use a few newer CSS features (chapter 9).
<dialog id="confirm-delete" aria-labelledby="cd-title" aria-describedby="cd-desc">
<h2 id="cd-title">Delete 3 files?</h2>
<p id="cd-desc">You can't undo this.</p>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
<script>
const d = document.getElementById("confirm-delete");
d.addEventListener("close", () => {
if (d.returnValue === "delete") deleteFiles();
});
openButton.addEventListener("click", () => d.showModal());
</script>
Check your understanding · Exercise 2
A dialog is rendered inside a card that has transform: translateY(-2px). Which statements are true? Select all that apply.
Decision 2: the component API
Forty teams will call this component. A good API makes the right thing easy and the wrong thing hard. The naive API is one boolean and one callback: isOpen and onClose(). It breaks within a week.
- The sign-in team just wants a button that opens a dialog. They don't want to create state, write a click handler, and pass a setter around for that.
- The settings team needs to know why the dialog is closing. Escape on a dirty form should ask "Discard changes?". A click on Save should not.
- Analytics wants to count how many people close the paywall dialog with the X versus clicking outside.
- A team wants to open a confirm box from code, after a failed request, and simply wait for yes or no.
Who owns the open state?
A controlled component gets its value from a prop and reports changes through a callback, like a React input with value and onChange. An uncontrolled component keeps its own value inside. Good design system components support both, the same way React's own input does with value or defaultValue.
Decision What shape should the API take?
What we pick: Compound components for everyday use, built on a public useDialog hook for rare custom cases, plus a small promise-based confirm() helper for code paths. The hook holds the logic, the parts hold the markup, and the helper covers "ask and wait".
type CloseReason =
| "escape" // Escape key or Android back gesture
| "backdrop" // click or tap outside the panel
| "close-button" // the X or a Dialog.Close
| "action" // a footer button such as Save
| "navigation" // the route changed (back button)
| "programmatic"; // the app called close()
type DialogRootProps = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean, reason?: CloseReason) => void;
// Return false to keep the dialog open, for example to ask about unsaved work.
onBeforeClose?: (reason: CloseReason) => boolean | Promise<boolean>;
role?: "dialog" | "alertdialog";
dismissible?: "any" | "escape-only" | "none";
size?: "sm" | "md" | "lg" | "fullscreen";
initialFocus?: React.RefObject<HTMLElement | null>;
returnFocus?: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
};
open plus onOpenChange is the controlled mode; defaultOpen is the uncontrolled one.A few choices hide in that type. Reasons are a union of strings, not booleans, so we can add a new one later without breaking callers. dismissible maps to the native closedby values: any, closerequest, none.
Sizes are named, not pixel widths, so the design team can change md from 560 px to 600 px in one place. On screens narrower than 640 px, md and lg become full-screen sheets automatically.
<Dialog.Root>
<Dialog.Trigger asChild><Button>Sign in</Button></Dialog.Trigger>
<Dialog.Content size="sm">
<Dialog.Title>Sign in to continue</Dialog.Title>
<SignInForm />
</Dialog.Content>
</Dialog.Root>
// Elsewhere, after a request fails:
const ok = await confirm({
title: "Retry upload?",
description: "2 of 5 files failed.",
confirmLabel: "Retry",
});
if (ok) retryUpload();
useDialog. The parts and confirm() are thin markup on top, and the browser does the hard parts below.Why both onOpenChange and onBeforeClose? They answer different questions. onBeforeClose asks "may I close?" and can say no, or wait for a second dialog.
onOpenChange says "it is now closed, and here is why". Mixing the two into one callback forces every team to remember to call setOpen(false) in exactly the right branch.
Check your understanding · Exercise 3
Which API choices help the settings team, whose dialog holds a long form? Select all that apply.
Decision 3: where focus goes, in and out
Focus is the one element that receives key presses. Keyboard users and screen reader users follow it the way mouse users follow the pointer. A dialog has three focus jobs: pick a good starting point, keep focus inside while open, and put it back somewhere sensible when closed.
Job 1: the first element to focus
The browser's rule (the autofocus element, else the first focusable one) is a good default, but "first focusable" is often the X button in the corner. That's a poor start for most dialogs. The WAI-ARIA Authoring Practices Guide (APG), the W3C's reference for accessible widgets, gives better advice, which this table turns into rules.
| Kind of dialog | Focus first | Why |
|---|---|---|
| Destructive confirm ("Delete 3 files?") | The Cancel button. | A quick Enter press should be the safe choice, never the one you can't undo. |
| Form (sign in) | The first input. | The user came here to type. |
| Long content (terms, release notes) | The title or a wrapper at the top, made focusable with tabindex="-1". | Focusing a button at the bottom would scroll the text out of view, and a screen reader would start reading in the middle. |
| Only information, one OK button | The OK button. | There is nothing else to do. |
In our API, teams pass initialFocus, a ref to the element they want. Our Dialog.Content calls showModal() and then focuses that ref. We don't lean on React's autoFocus prop, because React handles it in its own way at mount time, which can run before showModal().
Job 2: keep focus inside
A focus trap means Tab and Shift+Tab never reach the page behind. With a modal dialog, the browser does it by making the page inert. One detail surprises people: after the last button, Tab does not jump straight to the first one.
It moves into the browser's own interface (such as the address bar), and pressing Tab again brings it back to the first element in the dialog. The APG example wraps directly instead, using JavaScript. Both keep the user out of the page behind, which is the point.
Many accessibility experts prefer the browser's way, because it never locks a keyboard user out of the address bar. We keep it and don't add a JavaScript wrap. Try both in the lab below.
Job 3: give focus back
When the dialog closes, the native element returns focus to whatever had it before showModal(). Usually that's the trigger button, and all is well. The hard case is when that element no longer exists.
Picture a table of 50 invoices. Each row has a Delete button. The user presses Delete on row 12, confirms, and row 12 is removed.
Focus has nowhere to go, so the browser puts it on body. A keyboard user must now Tab from the top of the page past 30 links to get back to row 13. A screen reader may start reading the page from the top.
- If the caller passed a
returnFocusref, focus it. The invoice table passes the next row's Delete button, or the table's heading if it was the last row. - Else, if the saved element is still in the page (
isConnectedis true) and not inert, leave the browser's choice alone. - Else focus the main heading of the page (
h1withtabindex="-1"), so the user at least starts at a sensible place.
function settleFocus(saved: HTMLElement | null, fallback?: HTMLElement | null) {
if (fallback?.isConnected) {
fallback.focus();
return;
}
const lost = document.activeElement === document.body;
if (lost || !saved?.isConnected) {
document.querySelector<HTMLElement>("main h1[tabindex='-1']")?.focus();
}
}
body here. Our close handler checks isConnected and uses the caller's returnFocus target instead: the next row.Two more cases. If the dialog was opened from a menu item, the menu has closed by now, so return focus to the menu's button, not the vanished item. If it was opened with a keyboard shortcut such as ?, there's no trigger at all, and the element that had focus before is the right place to go back to, which is what the browser does.
Check your understanding · Exercise 4
A "Remove member" confirm opens from a row in a team list. After confirming, the row is gone. Where should focus go? Select all that apply.
Decision 4: hiding the page and naming the dialog
"The page behind is unreachable" must hold for three kinds of users, and each reaches the page in a different way.
- Mouse and touch users click things. The backdrop covers the page, so their clicks land on it.
- Keyboard users press Tab. The focus trap from the last chapter covers them.
- Screen reader users have a third way that people forget: a virtual cursor (also called browse mode). With arrow keys or swipes, they move through every piece of text in the page, without moving focus at all. A focus trap does nothing against it. A dialog that only traps Tab lets a VoiceOver user swipe right off the end of the dialog and start reading the page underneath.
Decision How do we hide the page behind from all three?
What we pick: Native showModal(). It gives the effect of inert on the whole page without us touching the page at all. If a team ever needs the custom div version, the fallback is inert on the app root, never aria-hidden alone.
showModal() makes every sibling inert at once, so all three ways of reaching the page are closed. A focus trap alone would only close the middle one.The ARIA dialog pattern
ARIA (Accessible Rich Internet Applications) is a set of attributes that tell assistive technology what an element is. The APG dialog pattern asks for a role, aria-modal="true", a name, and an optional description. The native element covers the role and the modal part; the table below gives our rules.
| Attribute | Our rule | What the user hears |
|---|---|---|
role="dialog" | Implied by the dialog element. Don't add it. | "dialog" after the title. |
role="alertdialog" | Set it for urgent questions the user must answer, such as "Delete 3 files?" or "Your session expires in 60 seconds". | Some screen readers announce it right away, like an alert, and read the description too. |
aria-labelledby | Points to Dialog.Title's id. Every dialog must have a name. If there's no visible title, use aria-label. | The dialog's name, read first. |
aria-describedby | Points to Dialog.Description, only when it's a short sentence. | The sentence, after the name. Never point it at a whole form or a list: it gets read as one long blob with no structure. |
With these in place, an NVDA or VoiceOver user who opens the delete confirm hears roughly: "Delete 3 files?, alert dialog. You can't undo this. Cancel, button." That one sentence tells them what opened, why, and where they are.
showModal() also exposes the dialog as modal to assistive technology, so aria-modal="true" is harmless but not needed.
function DialogTitle({ children }: { children: React.ReactNode }) {
const ctx = useDialogContext();
const id = useId();
useLayoutEffect(() => ctx.setTitleId(id), [ctx, id]);
return <h2 id={id}>{children}</h2>;
}
// In Dialog.Content:
<dialog
ref={ref}
role={role === "alertdialog" ? "alertdialog" : undefined}
aria-labelledby={titleId}
aria-describedby={descriptionId}
closedby={dismissible === "escape-only" ? "closerequest" : dismissible}
>
Check your understanding · Exercise 5
A custom div dialog traps Tab perfectly and has role="dialog". What can still go wrong? Select all that apply.
Decision 5: closing rules
Users must always find a way out. Yet the same click that should close a small confirm must not throw away ten minutes of typing in a settings form. So closing is not one rule: it depends on the kind of dialog and on whether there is something to lose.
| Way to close | Small confirm (alertdialog) | Info dialog | Form dialog |
|---|---|---|---|
| X button or Cancel | Closes. Reason close-button. | Closes. | Closes, after the unsaved-changes check. |
| Escape key | Closes, same as Cancel. | Closes. | Goes through the unsaved-changes check. |
| Click on the backdrop | Does nothing. An urgent question needs a real answer. | Closes. Reason backdrop. | Does nothing. One stray click is too easy. |
| Main action (Delete, Save) | Closes after the action. Reason action. | Closes. | Closes only when the save succeeds; on error it stays open and shows the error. |
Our dismissible prop encodes the backdrop column. Its default is "escape-only", because closing on an outside click is the risky choice and teams should turn it on deliberately. Escape stays on for nearly every dialog: keyboard users expect it, and the APG names it as the standard way out. The "none" value is for rare steps the user must finish, such as a required password change, and even then the dialog needs a visible button to move on.
The unsaved-changes check
Every close attempt goes through one function: requestClose(reason). It asks the team's onBeforeClose(reason). If that returns false, or a promise that resolves to false, the dialog stays open. The settings team returns true when the form is clean, and otherwise opens a small "Discard changes?" alertdialog on top and waits for the answer.
For Escape, the browser fires the cancel event before it closes the dialog. We call event.preventDefault() to stop the close, then run our check.
One limit: to stop pages from trapping people, the HTML standard lets browsers ignore that veto. In Chrome, cancel can only be blocked if the user has clicked or typed since the page last blocked one; otherwise Escape closes the dialog anyway. So show your question on the first Escape, and never block silently.
Two more notes. The native dialog.requestClose() method (in all major browsers since 2025) is not our requestClose(reason): it fires cancel and then closes, like Escape. For closing the whole browser tab with unsaved work, the dialog is not involved: that's a beforeunload listener on the page.
requestClose(reason), is the only path to the closed state. That's why a new way of closing, such as the back button, goes through the same unsaved-changes check.The drag that closes by accident
Here is a classic bug. A user selects text in an input by pressing the mouse inside the dialog, dragging to the left, and letting go over the dark backdrop.
A click event fires on the closest element that holds both the press and the release. Clicks on ::backdrop are reported on the dialog element itself, so that closest element is the dialog. A naive "if the click target is the dialog, close" handler closes the form and loses the text.
The fix: remember where the press started, and close only if both the press and the release were on the backdrop. To make "on the backdrop" easy to test, give the dialog no padding and put the visible panel in an inner div. Then any event whose target is the dialog element itself happened outside the panel.
let downOnBackdrop = false;
dialog.addEventListener("pointerdown", (e) => {
downOnBackdrop = e.target === dialog;
});
dialog.addEventListener("click", (e) => {
const upOnBackdrop = e.target === dialog;
if (downOnBackdrop && upOnBackdrop) requestClose("backdrop");
downOnBackdrop = false;
});
const nativeLightDismiss = "closedBy" in HTMLDialogElement.prototype;
Check your understanding · Exercise 6
A user is editing a profile in a form dialog. Which events should close it without asking? Select all that apply.
Decision 6: scroll locking and stacking
Freezing the page behind
Open a native modal dialog over a long page and spin the mouse wheel over the backdrop: the page behind scrolls. The browser makes the page inert, but inert doesn't stop scrolling. There is a second leak called scroll chaining: when the dialog's own body reaches its end, extra wheel or touch movement passes on to the page.
The naive fix is overflow: hidden on body while a dialog is open. On a Mac with hidden scrollbars it looks perfect.
On Windows, the scrollbar takes about 15 to 17 px. When it disappears, the page gets wider and everything behind the backdrop jumps sideways by that much, then jumps back on close. Users see the whole page twitch twice.
Decision How do we lock scrolling without the jump?
What we pick: Reserve the gutter with CSS, plus overscroll-behavior: contain on the dialog's scrolling body to stop chaining. Keep the padding version as a tiny fallback for browsers without scrollbar-gutter.
html {
scrollbar-gutter: stable;
}
html:has(dialog:modal) {
overflow: hidden;
}
.dialog-body {
overflow-y: auto;
overscroll-behavior: contain;
}
scrollbar-gutter: stable keeps that space even when overflow: hidden removes the scrollbar, so nothing behind the backdrop twitches.Stacking and nested dialogs
The top layer is a stack. Each showModal() puts its dialog on top of the stack, with its own ::backdrop right under it. Only the topmost modal is usable; everything below, including the first dialog, is inert.
Escape closes only the topmost one. When it closes, the one below becomes usable again, and focus returns to the button inside it that opened the second dialog. We don't write any of this: it falls out of the stack.
- Menus and popovers inside a dialog. A select menu opened with the
popoverattribute also enters the top layer, above the dialog, so it's never clipped by the dialog's scrolling body. - Toasts while a dialog is open. Content outside the topmost modal is inert, so a toast outside it can't be clicked and its live region may not be announced. Show the message inside the dialog instead.
- Portals. With the native element, we don't need a portal to win z-index fights. But the dialog still unmounts with its React parent. A dialog opened from a menu item must live above the menu (in the menu's owner), or it vanishes when the menu closes.
- React events still bubble through the React tree. A click inside a dialog rendered inside a clickable card also reaches the card's
onClick. Stop propagation atDialog.Content. - Depth. Allow two levels (a form, then "Discard changes?"). Three stacked dialogs is a sign the flow should be one dialog with steps.
Check your understanding · Exercise 7
A settings dialog is open, and a "Discard changes?" dialog opens on top of it. Which are true? Select all that apply.
Decision 7: animation and phones
Animating in and out with CSS only
A short fade and slide helps users see where the dialog came from. It used to need JavaScript, for two reasons.
First, a closed dialog has display: none, and CSS could not transition to or from none. Second, on close the dialog leaves the top layer at once, so even a fade-out would happen behind the page. Newer CSS features solve both.
@starting-stylegives the values for the very first frame after an element appears. Without it, the dialog is born already at full opacity, so there's nothing to animate from.transition-behavior: allow-discretelets properties that can't blend, such asdisplayandoverlay, take part in a transition. They flip at the end of the exit, so the dialog stays visible, and in the top layer, while it fades out.overlayis the property that says whether an element is in the top layer. Transitioning it is what keeps a closing dialog on top for those last 150 ms. Only Chrome and Edge support this so far.
dialog, dialog::backdrop {
transition: opacity 150ms ease-out, translate 150ms ease-out;
opacity: 0;
}
dialog { translate: 0 8px; }
dialog[open], dialog[open]::backdrop { opacity: 1; translate: 0 0; }
@starting-style {
dialog[open], dialog[open]::backdrop { opacity: 0; }
dialog[open] { translate: 0 8px; }
}
/* Fade out only where the dialog can stay in the top layer meanwhile. */
@supports (overlay: auto) {
dialog, dialog::backdrop {
transition: opacity 150ms ease-out, translate 150ms ease-out,
display 150ms allow-discrete, overlay 150ms allow-discrete;
}
}
@media (prefers-reduced-motion: reduce) {
dialog, dialog::backdrop { transition: none; }
}
overlay can be transitioned, and no motion at all for reduced motion.@starting-style supplies the 0 on the left. In Chrome and Edge, allow-discrete delays the flip of display and overlay to the right edge, so the fade-out stays visible and on top. Other browsers skip the fade-out.Support is good but not complete. The enter animation works in all major browsers. The exit animation depends on transitioning overlay, which only Chrome and Edge support so far. Elsewhere the @supports check leaves the exit transition off, so the dialog simply disappears on close: a fine fallback that needs no JavaScript.
Focus moves the moment showModal() runs, not after the animation, so keyboard users never wait. Keep it short: 150 to 200 ms in, the same or a bit less out. People who set reduced motion in their system settings (prefers-reduced-motion: reduce) get no movement; motion can make some of them feel sick.
Phones: sheets, the keyboard, and dvh
On a 375 px wide phone, a centered 560 px card doesn't fit, and a centered card with a form leaves little room once the on-screen keyboard opens. So below 640 px, md and lg dialogs become full-screen sheets: a top bar with Close, the title, and the main action, then a scrolling body. Small confirms stay as centered cards; they fit.
- Use
dvh, notvh. On phones,100vhmeans the height with the address bar hidden, so the bottom 60 to 100 px of a full-screen sheet hide behind the browser's toolbar.100dvh, the dynamic viewport height, follows the toolbar as it shows and hides. - The keyboard covers the bottom. On iOS Safari, the keyboard shrinks only the visible area, not the layout, so even
dvhdoesn't change. We put the main action in the top bar, not a bottom footer, and scroll the focused input into view withscrollIntoView({ block: "nearest" }). Chrome on Android can resize the layout instead, withinteractive-widget=resizes-contentin the viewport meta tag. For more control, thevisualViewportresize event tells us how much space is really left. - Safe areas. Add
env(safe-area-inset-bottom)to the bottom padding so content clears the home indicator on phones without a home button. It only has a value when the viewport meta tag includesviewport-fit=cover. - Touch targets. WCAG 2.2 AA asks for at least 24 by 24 CSS px. We use 44 by 44 for the close button and actions, because a missed tap on a small X is the most common mobile complaint about dialogs.
100dvh, with the main action in the top bar, so the keyboard doesn't cover it.Check your understanding · Exercise 8
Which statements about animation and phones are correct? Select all that apply.
Decision 8: dialogs, the URL, and the back button
Most dialogs are short questions and have no business in the address bar. A few are places: the settings panel, a photo opened from a feed, a "share" sheet. People want to send a link to them, and they expect the browser back button to close them.
On Android, pressing back to close something is a habit, and recent Chrome there treats the back gesture as a close request for an open modal dialog. Other browsers, and every desktop back button, skip a dialog whose open state lives only in React state, and leave the page. Losing a whole feed scroll position because you wanted to close a photo is a common complaint on the mobile web.
Decision Where does a dialog's open state live?
What we pick: Choose per dialog, through the same controlled API. Confirms use component state. Settings uses a query parameter. Content like photos uses a real route. In the URL cases the URL is the single source of truth: open is computed from it, and closing changes the URL.
Keeping the two in sync has a few traps. When the user closes with the X, we must undo our history entry with history.back(), otherwise back later reopens the dialog. But if the user arrived from a shared link, there's no entry of ours to go back to, and history.back() would leave the site. So we store a flag in the history state when we push, and close by going back only when the flag is there; otherwise we replace the URL without the parameter.
function openSettings() {
const url = new URL(location.href);
url.searchParams.set("dialog", "settings");
history.pushState({ pushedDialog: true }, "", url);
}
function closeSettings() {
if (history.state?.pushedDialog) {
history.back(); // popstate re-renders with open = false
} else {
const url = new URL(location.href);
url.searchParams.delete("dialog");
history.replaceState(null, "", url);
}
}
- Escape and Android back on a URL dialog. Both fire
cancelon the native dialog. We prevent the default close and callcloseSettings(), so the URL leads and the dialog follows. If the browser won't let us cancel (the rule from chapter 7), ourcloseevent handler fixes the URL instead. Either way, the URL and the dialog end up in agreement. - Unsaved changes and back. A
popstateevent can't be canceled; by the time it fires, the URL has changed. We push the entry again and show "Discard changes?". The newer Navigation API fires anavigateevent before the URL changes, but browsers let a page cancel a back navigation only in limited cases, so we don't rely on it. - Focus. A dialog closed by back still returns focus to its trigger, like any other close.
Check your understanding · Exercise 9
Someone opens a shared link to /account?dialog=settings in a new tab, then clicks the X. What should happen? Select all that apply.
Putting it together, and testing it
Here is the whole component in one table. Notice how little code is ours. The browser does the hard parts; each of our parts is small and has one job.
| Part | Job | Roughly how big |
|---|---|---|
useDialog hook | Holds open state (controlled or not), requestClose(reason) with the onBeforeClose check, and the saved trigger. | 60 lines |
Dialog.Root and Dialog.Trigger | Provide the hook through context; the trigger opens on click and is saved for focus return. | 30 lines |
Dialog.Content | Renders the dialog, calls showModal() and close() when open changes, sets initial focus, handles cancel, light dismiss, and focus fallback. | 90 lines |
Dialog.Title, Description, Footer, Close | Markup with ids wired to aria-labelledby and aria-describedby, and close buttons that report close-button. | 40 lines |
confirm() helper | Mounts a small alertdialog and returns a promise of true or false. | 40 lines |
| CSS | Sizes, sheet layout under 640 px, scroll lock, animation, reduced motion. | 80 lines |
The single most important line in Dialog.Content is the effect that turns the open prop into method calls. React can't express "modal" with an attribute, so we bridge it by hand, and also listen for the browser closing the dialog on its own (Escape, form method="dialog") so our state follows.
useLayoutEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
initialFocus?.current?.focus();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open, initialFocus]);
// The browser closed it (Escape that we didn't cancel, a dialog form, closedby):
onClose={() => { if (open) onOpenChange?.(false, lastReason.current); }}
Testing: real browsers, real keys, real screen readers
Almost everything that matters here is browser behavior: the top layer, inert, focus return, cancel. jsdom, the fake browser most unit tests use, doesn't implement showModal(). Mocking it would test our mock, not the dialog. So the component tests run in a real browser, with Playwright or Vitest's browser mode, and they press real keys.
| What to check | How |
|---|---|
| The dialog has a role and a name | getByRole("alertdialog", { name: "Delete 3 files?" }). If the name is missing, the query fails. |
| Initial focus | After opening, the Cancel button is focused (toBeFocused()). |
| Tab never reaches the page | Press Tab 10 times; focus is always inside the dialog or nowhere in the page. |
| Escape closes with the right reason | Spy on onOpenChange and expect (false, "escape"). |
| Drag from inside to outside doesn't close | mouse.down() in an input, mouse.move() to the backdrop, mouse.up(); the dialog is still open. |
| Focus returns, even when the trigger is gone | Delete a row through its confirm; expect the next row's button to be focused. |
| No layout shift | Compare the main heading's bounding box before and after opening, on Windows-style scrollbars. |
| Automated accessibility rules | Run axe-core on the open dialog in each size. |
test("focus moves in and returns to the trigger", async ({ page }) => {
await page.goto("/files");
const trigger = page.getByRole("button", { name: "Delete selected" });
await trigger.click();
const dialog = page.getByRole("alertdialog", { name: "Delete 3 files?" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
Automated tools catch only part of accessibility problems. Before each release, someone does a short manual pass with VoiceOver on macOS and iOS, NVDA with Firefox or Chrome on Windows, and TalkBack on Android: open, read, swipe to the end, close, and check where focus lands. Visual regression screenshots cover each size, each theme, and the phone sheet.
Check your understanding · Exercise 10
Which tests would catch a real dialog bug that a jsdom unit test with a mocked showModal() would miss? Select all that apply.
Running it in the interview
A dialog question is a test of depth on a small surface. The interviewer knows you can center a box. They want to hear focus, inert, closing rules, and what the platform does for you. Spend little time on visuals and most of it on behavior.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions: kinds of dialogs, browsers, URL needs, nesting. | "Modal means nothing else can be reached by mouse, keyboard, or screen reader while it's open." |
| 5 to 10 | Draw the anatomy, then write the root props type. | "Controlled or uncontrolled, and every close tells you why." |
| 10 to 15 | Native dialog versus a custom div. List what showModal() gives and the gaps. | "The top layer ends z-index fights, and the browser makes the page inert for us." |
| 15 to 30 | Deep dive: focus in, trap, return (with the deleted-row case), ARIA names, closing rules, the dirty form, the drag bug. | "Focus is how keyboard and screen reader users know where they are." |
| 30 to 38 | Scroll lock without layout shift, nesting, animation, phone sheets. | "scrollbar-gutter: stable and a :has(dialog:modal) rule, no JavaScript." |
| 38 to 45 | Routing and the back button, then testing in real browsers. Take follow-ups. | "The URL is the source of truth when a dialog is a place." |
Draw the anatomy first, because every later point can point at it: "focus starts here", "this click closes, this one doesn't". If the interviewer pushes toward a custom build, don't argue; show you know what you'd have to rebuild (portal, trap, inert, Escape, focus return), which is the same list in reverse.
Think first
Follow-up: "Why not just call window.confirm()?"
It can't be styled or branded, and it can't hold a form or rich text.
It blocks the page's main thread until answered: no timers, event handlers, or network callbacks run.
Browsers may skip it: in a sandboxed iframe without allow-modals, or after the user ticks "don't let this page create more dialogs", it returns false without showing anything.
It's still fine for a quick internal tool. For a product, our confirm() helper gives the same one-line call with a real dialog.
Think first
Follow-up: "How does this change for a non-modal panel, like a chat window that stays open while you browse?"
Open it with show() instead of showModal(): no top layer, no backdrop, no inert page.
No focus trap. Tab moves freely between the panel and the page, and a shortcut such as F6 can jump between them.
Escape may still close it, but focus doesn't have to move in on open if the user didn't ask for it.
It needs a normal z-index or the popover attribute to stay on top.
Think first
Follow-up: "A cookie consent banner opens as a modal on page load. Any concerns?"
It blocks every first-time visitor, including screen reader users, before they see any content. A large banner can even become the page's Largest Contentful Paint element.
There is no trigger, so focus return goes to the top of the page. That's acceptable here.
Prefer a non-modal banner at the bottom, unless the law in a market truly requires a choice before any use.
Summary
- Modal means nothing else on the page can be reached by mouse, keyboard, or screen reader. Every design choice follows from that.
- Build on the native
dialogelement andshowModal(): the top layer,::backdrop, an inert page, Escape throughcancel, and focus in and out come for free. Never open a modal with theopenattribute. - Fill the gaps yourself: scroll lock, light dismiss where
closedbyisn't supported, a focus target when the trigger is gone, an unsaved-changes check, and exit animations. - Support controlled and uncontrolled use, report every close with a reason, and let callers refuse a close with
onBeforeClose. - Pick the first focus on purpose: Cancel for destructive confirms, the first input for forms, the top of the content for long text.
- Every dialog needs a name through
aria-labelledby; usealertdialogonly for urgent questions, and keeparia-describedbyto one short sentence. - Close on the backdrop only when the press and the release were both outside, and only for dialogs with nothing to lose.
- Lock scroll with
scrollbar-gutter: stableandhtml:has(dialog:modal), animate with@starting-styleandallow-discrete, and usedvhand full-screen sheets on phones. - When a dialog is a place, the URL is the source of truth and back closes it. Test in real browsers and with real screen readers.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: The dialog element showModal(), returnValue, the cancel and close events, and closedby.
- WAI-ARIA APG: Dialog (Modal) pattern Keyboard behavior, initial focus advice, and the ARIA attributes.
- WAI-ARIA APG: Alert and Message Dialogs pattern When to use alertdialog.
- HTML Standard: the dialog element The exact focus and close rules browsers follow.
- MDN: The inert attribute The fallback for custom dialogs.
- Chrome for Developers: Four new CSS features for smooth entry and exit animations @starting-style, transition-behavior, and overlay.
- MDN: scrollbar-gutter Reserving the scrollbar's space to avoid the jump.
- web.dev: The large, small, and dynamic viewport units Why 100vh fails on phones and what dvh fixes.
Concepts behind it
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.