modal
A modal is a dialog that opens over the page for one task that needs an answer before the page can go on, such as confirming a delete. Until people answer or close it, the page behind it is dimmed and waits.
when to use
- To confirm an action that deletes something or can't be undone, such as “delete volume” or “revoke token”.
- To collect two or three values for one task without leaving the page, such as “add server”.
- When the next step depends on the answer, and nothing else on the page should happen until it is given.
when to use something else
- To report that something happened. Use a toast for a passing confirmation, or a banner for a message that stays on the page. A dialog that only says “saved” interrupts people and asks them nothing.
- In front of an action that can be undone. Do it, and confirm it with a toast. The way back is the opposite action on the page, because toasts carry no actions.
- For a long form, or several steps. Give it a page of its own; see forms and settings.
- For a short explanation. An optional detail fits a tooltip, and help for an input is the hint in its field.
- To offer a list of actions. Use an action menu.
anatomy
- Backdrop. A dark veil over the whole page, in the colour of the canvas, through which the page shows faintly.
- Dialog. A square panel with a thin border, in the middle of the screen. It has a set maximum width, is never taller than the screen, and keeps a small margin from the screen's edges.
- Header (only with a title). A thin rule runs below it. It holds:
- Title. A heading in monospace, shown in lowercase.
- Close button. A small square with a cross, named “close”.
- Body. The content, in the body face. When it is taller than the dialog, it scrolls, and the header and the footer stay in place.
- Footer (optional). The actions, lined up on the right, with a thin rule above them.
variants and states
A modal can be set up in four ways. The example at the top of this page is a confirm.
| setup | how | use it for |
|---|---|---|
| Confirm | A title, one or two sentences, and a footer with “cancel” and the action | An action that deletes something or can't be undone |
| Form | A title, fields in the body, and a footer | A few values for one task |
| No title | A name for assistive technology instead of a title | A short readout. With no header there is no close button, so put one in the footer |
| Persistent | esc and the backdrop don't close it | Work that a stray key shouldn't hide. The close button in the header and the buttons in the footer still close it |
form
A form in the body can be sent from a button in the footer, and enter in a field sends it too.
no title
Without a title, the modal has no header and no close button. It needs a name of its own instead, and a way out in its footer.
persistent
A persistent modal stays open when people press esc or click the backdrop, so a stray key can't hide work that is under way.
long content
Long content scrolls inside the body, while the title and the actions stay in view. The modal has no animation when it opens or closes.
behaviour
- When it opens, focus moves to the first control in the dialog. With a title, that is the close button in the header. Without one, it is the first control in the body or the footer, or the dialog itself when there is none.
- While it is open, tab and shift + tab stay inside the dialog: from its last control, focus wraps round to the first, and back. The page behind doesn't scroll.
- Closing. esc, a click on the backdrop and the close button all close it; a persistent modal ignores esc and the backdrop. When an action menu in the dialog is open, the first esc closes the menu, and the next one closes the dialog.
- When it closes, focus goes back to whatever had it when the modal opened, usually the button that opened it.
The full keyboard map is on the modal's React page.
accessibility
- The title names the dialog. Without a title, give the dialog an accessible name instead. With neither, the dialog has no name.
- The title is always a level-2 heading.
- The close button is named “close”, and it is larger than the minimum target size.
- A click on plain text in the dialog puts focus on the dialog itself; tab and shift + tab from there stay inside.
Today:
- You can't choose where focus starts. With a title, it always starts on the close button.
- A screen reader announces the title, not what will happen, so make the title say what happens: “delete volume”, not “are you sure?”.
- Open one modal at a time. Every open modal listens for esc, so one press closes them all.
- The page's scrolling is locked, but nothing makes up for the width of its scrollbar, so the page behind can shift sideways when the modal opens.
- If the control that opened the modal is gone when it closes, because you deleted its row for example, focus has nowhere to go back to. Move it yourself, to the next sensible place.
content
- Title. A verb and its object, in the same words as the control that opened it: “delete volume”, “add server”. Not a question, and no full stop. The lowercase look is only a style, so write the title exactly as it should be read. A name in it keeps its capitals: “pick a release for The Quiet Orchard”.
- Body. One or two sentences, in sentence case: what happens, to what, and whether it can be undone. Name the object: “This permanently deletes storage-01 and its 3 snapshots.”
- Actions. The button that confirms repeats the title's verb, as in “delete volume”, and the way out is “cancel”, as a quiet button. The confirming action goes last, on the right. Use the danger button only for an action that deletes something or can't be undone.
do and don't
related
- Button: the button that opens the modal, and the actions in its footer.
- Toast: confirms what happened, without blocking the page.
- Banner: a message that stays on the page.
- Forms and settings: where longer forms go.
specs
| part | spec |
|---|---|
| Layer | Nothing renders while open is false. Open, the modal renders into the page's <body> (a React portal): a fixed wrapper over the whole viewport, role="presentation", z-index: 100, with 16px of padding, so the dialog keeps 16px from the viewport's edges. |
| Backdrop | --bg, the canvas colour, at 72% opacity, over the whole viewport. aria-hidden="true". |
| Dialog | role="dialog", aria-modal="true", tabindex="-1". --surface fill, a 1px --border-2 border, square, centred, text in --fg-2. As wide as there is room for, up to width (480px by default); at most the viewport's height minus 48px. className lands here. |
| Header | Only with title. Padding 12px 16px, 12px between the title and the close button, a 1px --border rule below. |
| Title | title: an h2 that names the dialog (aria-labelledby). JetBrains Mono 600, 15px, --fg, lowercased with text-transform; a name wrapped in KeepCase keeps its case. |
| Close button | 26px square, above the 24px minimum target size, with a 14px × in --fg-muted; named “close” (aria-label). Hover: a --surface-2 fill and --fg, in 120ms ease. Keyboard focus: the 2px --focus-ring outline, 1px outside. |
| Body | children. Padding 16px, Inter 14px, line-height 1.55. It scrolls (overflow-y: auto) when the content is taller than the dialog. |
| Footer | footer. The actions right-aligned, 8px apart; padding 12px 16px, a 1px --border rule above. |
| setting | in code | what it does |
|---|---|---|
| Open and close | open, onClose |
esc, a click on the backdrop and the close button call onClose. The modal never closes itself: you set open to false. |
| No title | ariaLabel |
Names the dialog (aria-label) when there is no title. |
| Persistent | closeOnEsc={false} and closeOnBackdrop={false} |
Both are true by default. |
| Width | width |
The largest width, in px. Default 480. |
| A form in the body | An id on the <form>, and type="submit" with form={id} on the footer button |
The footer button sends the form, and so does enter in a field. |
- Focus on open: the first
button,[href],input,select,textareaor[tabindex](other than-1) in the dialog, or else the dialog itself. On close, focus returns to the element that had it when the modal opened. - Focus trap: tab and shift + tab wrap between the first and the last enabled controls; with none, tab does nothing.
- Esc: heard on the whole document, after the dialog's own controls, and only when none of them has called
preventDefault()on it. That is how an open action menu takes the first press. - Scroll lock:
overflow: hiddenon the page's<body>while open, with no room made for the scrollbar. - Motion: none when it opens or closes.