Skip to main content
Graphite UI

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.

Contract 2.0.0Kit · 2 sets · 17 variantsOpen in Figma

Live preview

Rendered by the component itself from the same generated tokens as the rest of the site.

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.json
import { 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.

  1. LabelOptionalThe kit's Label, a 12/16 line 4 above the title.
  2. TitleRequiredBody/1 18/28 Regular. Also the dialog's accessible name.
  3. 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.
  4. ProgressOptionalThe kit's Progress, a block between the header and the body, 16 above and below and 24 before the body.
  5. BodyRequiredBody/3, inset 16, with 48 below it.
  6. 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.

Size: Extra small
Size: Small
Size: Medium
Size: Large
Actions: 3
Actions: 2 and Cancel
Progress
Inline loading

API reference

Generated from the contract, versioned with it, and checked by drift-check. This table cannot describe props the component does not have.

Modal props
PropTypeDefaultNotes
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.
dismissibleboolean—
loadingstring—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" with aria-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-surface on background, as the kit binds them, a pairing the engine checks at the theme’s target, AA or AAA. The label is on-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 axes and how they map to code
Kit axisValuesCodeHow it maps
SizeLarge · Medium · Small · Extra small · Mobilesizelg, 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 · SlotBooleanslabel · dismissible · progress · bodyThe 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: Actions1 · 2 · 3footerFull-bleed 64px columns, 1px apart, from the right: two columns for one or two actions, four for three. No divider.
Footer: CancelTrue · Falsea ghost button firstPinned to the leftmost column, as the kit draws it.
Footer: Inline loadingTrue · FalseloadingThe primary column shows the text with a spinner.
FillBackground/background—background, as the set binds it. The scrim does the separating, so no shadow.