Modal
A dialog that stops the page until the reader answers it. Use it for a decision that has to be made before anything else can happen, not for news the reader could take in without stopping.
Live preview
Rendered by the component itself from the same generated tokens as the rest of the site.
const [open, setOpen] = useState(false)
<Button onClick={() => setOpen(true)}>Open modal</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
label="Library"
title="Publish this theme?"
body="Everyone on the library gets the new tokens on their next sync."
footer={[
<Button key="back" onClick={() => setOpen(false)}>Back</Button>,
<Button key="publish" variant="primary" onClick={publish}>Publish</Button>,
]}
/>Installation
Install it with the shadcn CLI, along with everything it uses. New project? Set it up first, as Quick start shows.
npx shadcn@latest add https://www.graphite-ui.com/r/modal.jsonimport { Modal } from '@/components/ui/modal'Anatomy
An optional label, the title and the close in a 16px header; the body inset 16; and the kit’s footer, a full-bleed row of 64px buttons. The panel is background over the scrim. The title is also the dialog’s accessible name, so it is required.
Library
Publish this theme?
- LabelOptionalThe kit's Label, a 12/16 line 4 above the title.
- TitleRequiredBody/1 18/28 Regular. Also the dialog's accessible name.
- CloseOptionalThe kit's Close icon, a 20px cross 16 from the top and right in a 48px target. Shown while the Modal is dismissible; a Modal that cannot be dismissed has no close, so the footer is its way out.
- ProgressOptionalThe kit's Progress, a block between the header and the body, 16 above and below and 24 before the body.
- BodyRequiredBody/3, inset 16, with 48 below it.
- Footer actionsOptionalTypically Button. Laid out as the kit's footer, a full-bleed row of 64px columns 1px apart, from the right; a ghost button first is the kit's Cancel, pinned to the left.
Variants
Size caps the width at 320, 384, 512 or 672 pixels; under 672px of viewport the dialog is full bleed, the kit’s Mobile. The footer fills two columns from the right for one or two actions and four for three, with a ghost Cancel pinned to the left. Progress sits between the header and the body, and Inline loading takes the primary column while the action runs.
Publish this theme?
Publish this theme?
Library
Publish this theme?
Library
Publish this theme?
Publish this theme?
Publish this theme?
Publish this theme?
Publish this theme?
API reference
Generated from the contract, versioned with it, and checked by drift-check. This table cannot describe props the component does not have.
| Prop | Type | Default | Notes |
|---|---|---|---|
| size | 'xs' | 'sm' | 'md' | 'lg' | — | The kit's Size. Width caps of 320, 384, 512 and 672; under 672px of viewport the dialog is full bleed, the kit's Mobile. |
| dismissible | boolean | — | |
| loading | string | — | The kit's Inline loading. The primary action's column shows this text with the spinner while the action runs. |
Design tokens
Every swatch is live. Change the source color in the header and this table repaints, because it reads the same roles the component does.
backgroundThe panel, as the Modal set binds it (Background/background). The scrim separates it from the page, so it takes neither the overlays' elevation nor their shadow.scrimFull-screen scrim at a defined opacity over the base surface.on-surfaceTitle, body and the close glyph.on-surface-variantThe label and the loading text.textThe label at 12/16, the title at Body/1, the body at Body/3.spacingPadding, the title's clearance of the close, the footer row and size steps.radiusPanel corner.motionThe entrance fade on the scrim, shared with the other overlays. Opacity only, and no exit, since the Modal unmounts on close.
Usage
The contract's prohibitions, written as the choices you will actually face.
Do
- Write the title as the question the reader is answering. It is also what a screen reader announces when the dialog opens.
- Put one primary action last in the footer. Pass a ghost Cancel first when there is one, and the footer pins it to the left as the kit does.
- Set dismissible to false only when an accidental close would lose work, and then make sure the footer offers a way out.
- Pick the smallest size that holds the body without scrolling.
Don’t
- Open a Modal from inside another Modal. Stack depth is one, and the inner one throws when it opens.
- Use a Modal for a message the reader can act on later. A Notification on the page does that without stopping them.
- Leave out the footer on a Modal that is not dismissible. Escape, the scrim and the close button are all off, so the reader would be stuck.
- Put a second primary action in the footer. The ButtonGroup around it throws rather than let the decision go unmade.
Accessibility
What the component does for you, and what it leaves to you.
- Keyboard
- Tab and Shift+Tab cycle through the dialog’s controls and wrap at either end. Escape closes it unless dismissible is false, and so does the close button, named “Close”.
- Roles
- The panel is
role="dialog"witharia-modal="true", labelled by its title. The page behind is not made inert, so the focus trap is what keeps keyboard users inside. - Focus
- On open, focus moves to the dialog itself, which takes it without a ring because it is a container, not a control. On close, focus returns to the element that opened it, every time.
- Pointer
- A press on the scrim closes a dismissible Modal, and focus goes back to the trigger. A press inside the panel never does. The scrim press is the Overlay base’s outside press, so dismissible turns it off together with Escape.
- Contrast
- Title and body are
on-surfaceonbackground, as the kit binds them, a pairing the engine checks at the theme’s target, AA or AAA. The label ison-surface-variant. - Loading
- Inline loading replaces the primary button with its text and a spinner, announced as a status, so the action cannot be pressed twice while it runs.
- Motion
- The scrim and panel fade in together on the fast motion step, opacity only. Under prefers-reduced-motion it appears at once. It does not animate out.
Figma parity
The kit’s Modal set has one axis, Size, and six booleans; its footer is a private set with three more axes. dismissible has no kit counterpart: whether Escape and the scrim close the dialog is behaviour, which a frame cannot show.
| Kit axis | Values | Code | How it maps |
|---|---|---|---|
| Size | Large · Medium · Small · Extra small · Mobile | size | lg, md, sm and xs at 672, 512, 384 and 320. The kit draws every size 671 wide; the code steps the caps. Mobile is the dialog full bleed under 672px of viewport, with the scrim’s inset dropped. |
| Label · Close icon · Progress · Description · Slot | Booleans | label · dismissible · progress · body | The label 4 above the Body/1 title; the 20px close 16 from the corner while the Modal is dismissible; a progress block before the body; the body at Body/3, as the description and slot both are. |
| Footer: Actions | 1 · 2 · 3 | footer | Full-bleed 64px columns, 1px apart, from the right: two columns for one or two actions, four for three. No divider. |
| Footer: Cancel | True · False | a ghost button first | Pinned to the leftmost column, as the kit draws it. |
| Footer: Inline loading | True · False | loading | The primary column shows the text with a spinner. |
| Fill | Background/background | — | background, as the set binds it. The scrim does the separating, so no shadow. |
