Skip to content
Primitiv home
Framework
Consumption mode

Modal

stableSource Figma

An accessible modal dialog built on the native <dialog> element — a fixed-width surface with an optional dimmed backdrop (WAI-ARIA Modal Dialog pattern).

Playground

Density

Preview

Size
import { Modal, ModalTrigger, ModalPortal, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalTitle, ModalDescription, ModalClose } from "@/components/ui/modal";import { Close } from "@primitiv-ui/icons";import { Button } from "@/components/ui/button";
<Modal>  <ModalTrigger asChild>    <Button>Open dialog</Button>  </ModalTrigger>  <ModalPortal>    <ModalContent size="md">      <ModalHeader>        <ModalTitle>Payment details</ModalTitle>        <ModalClose asChild>          <Button variant="ghost" size="sm" aria-label="Close">            <Close />          </Button>        </ModalClose>      </ModalHeader>      <ModalBody>        <ModalDescription>          We only charge the card once the order ships.        </ModalDescription>      </ModalBody>      <ModalFooter>        <ModalClose asChild>          <Button variant="secondary">Cancel</Button>        </ModalClose>        <Button>Confirm</Button>      </ModalFooter>    </ModalContent>  </ModalPortal></Modal>

Density is set by a data-density ancestor — the Context system, not a Modal prop.

Installation

npx primitiv add modal

Import

import { Modal } from "@/components/ui/modal";

Copied into your project as .primitiv-modal — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Modal>  <ModalTrigger />  <ModalPortal>    <ModalContent>      <ModalHeader>        <ModalTitle />        <ModalClose />      </ModalHeader>      <ModalBody>        <ModalDescription />      </ModalBody>      <ModalFooter />    </ModalContent>  </ModalPortal></Modal>

Props

Modal.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
childrenReactNodeheadlessThe modal's sub-components (Trigger, Portal, Overlay, Content, ...).
defaultOpenbooleanfalseheadlessWhether the modal is open on first render. The component owns the flag from then on. Omit for closed-on-mount. Forbidden in controlled mode — use open instead.
onOpenChange(open: boolean) => voidheadlessCalled with the new open state whenever it changes (open or close). Optional in uncontrolled mode — useful purely to observe transitions. Called with the requested open state whenever the modal wants to open or close (trigger click, Close button, Esc, click-outside, imperative API). Required in controlled mode.
openbooleanheadlessForbidden in uncontrolled mode — use defaultOpen instead. The current open state. Must be kept in sync by the parent via onOpenChange.
refRef<ModalImperativeApi>headlessRef receiving the imperative ModalImperativeApiopen/close.

Modal.Trigger

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender into the consumer's own element (e.g. a router <Link>) instead of the default <button>, merging the trigger's ARIA wiring, composed event handlers, and ref onto it via the Slot pattern.

Modal.Portal

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
childrenReactNodeheadlessThe dialog subtree to portal — typically Modal.Overlay + Modal.Content.
containerHTMLElementdocument.bodyheadlessTarget element to portal into.
forceMountbooleanfalseheadlessKeep the portalled subtree mounted after open flips false, so a CSS exit animation keyed off data-state="closed" can play. Without it the portal unmounts as soon as the modal closes.

Modal.Overlay

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender into the consumer's own element (e.g. a motion wrapper) instead of the default <div>, merging the overlay's aria-hidden and data-state onto it via the Slot pattern.
forceMountbooleanfalseheadlessKeep the overlay mounted after open flips false so a CSS fade-out can play. Without it the overlay unmounts as soon as the modal closes.

Modal.Content

Extends HTMLDialogElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
onEscapeKeyDown(event: Event) => voidheadlessFires on the dialog's native cancel event when the user presses Escape. Call event.preventDefault() to veto closing and keep the modal open (e.g. to confirm unsaved changes first).
onPointerDownOutside(event: PointerEvent) => voidheadlessFires on a pointerdown whose coordinates land outside the dialog's bounding rect — i.e. on the browser-painted ::backdrop. Call event.preventDefault() to veto closing and keep the modal open.
size"sm" | "md" | "lg" | "xl"mdstyledDialog width; data-density scales the padding within each size.

Modal.Header

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Modal.Body

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Modal.Title

Extends HTMLHeadingElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender into the consumer's own heading element (e.g. an <h3> for a nested dialog) instead of the default <h2>; the generated id is still registered for aria-labelledby.

Modal.Description

Extends HTMLParagraphElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender into the consumer's own element instead of the default <p>; the generated id is still registered for aria-describedby.

Modal.Close

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender into the consumer's own element (e.g. an icon-only button) instead of the default <button>, merging the close behaviour in. The consumer's onClick runs first and can event.preventDefault() to veto.

Styling contract

--primitiv-modal-surface--primitiv-modal-fg--primitiv-modal-shadow--primitiv-modal-scrim--primitiv-modal-radius--primitiv-modal-padding-inline--primitiv-modal-padding-block--primitiv-modal-gap--primitiv-modal-width--primitiv-modal-border-width--primitiv-modal-border-color--primitiv-modal-divider-color--primitiv-modal-title-color--primitiv-modal-title-font-family--primitiv-modal-title-font-size--primitiv-modal-title-font-weight--primitiv-modal-title-line-height--primitiv-modal-description-color--primitiv-modal-description-font-family--primitiv-modal-description-font-size--primitiv-modal-description-font-weight--primitiv-modal-description-line-height

Keyboard

KeyBehaviour
Tab / Shift+TabMove through the dialog's focusable content, wrapping at each end.
EscapeClose the dialog and return focus to the trigger. onEscapeKeyDown can veto it.
Enter / SpaceActivate the focused control — the platform's, since every part is a real <button>.

Data attributes

ModalOverlay

className: .primitiv-modal__overlay

AttributeValueWhen
data-stateopen | closedopen / closed

ModalContent

className: .primitiv-modal

AttributeValueWhen
data-stateopen | closedopen / closed

Accessibility

  • It is a real <dialog>, opened with showModal(). That is what supplies the top-layer stacking and the inert background — everything behind it leaves the tab order and the accessibility tree — rather than a z-index and an aria-hidden sweep that has to be kept in step.
  • Tab wraps, which the platform does not do on its own. A native modal dialog stops Tab escaping to the page but lets it walk out to the browser chrome; a boundary-only handler sends the last element's Tab back to the first and the first's Shift+Tab to the last, which is what the WAI-ARIA APG Modal Dialog pattern requires. Only the two boundaries are intercepted, so the browser's own inert-aware order still governs everything in between.
  • Modal.Title and Modal.Description register their ids as aria-labelledby and aria-describedby. A dialog with no title is announced as an unnamed dialog — if the visible design has no heading, keep the title and hide it visually rather than dropping the part.
  • Focus returns to the trigger on close, every route out included. The one case to handle yourself: if the element that opened the dialog is gone by the time it closes — a row that the dialog deleted — move focus somewhere deliberate.
  • Escape closes, and onEscapeKeyDown can veto it for unsaved work. A veto is a promise to offer another way out, so keep a visible Cancel or close button.
  • Modal.Close is behaviour, not an icon: with asChild it wraps your own button, so an icon-only close needs its own aria-label — the glyph carries no accessible name.
  • For the common confirm/cancel case, reach for ConfirmDialog instead. It composes this component with Button and settles the tone, the labels and the button order for you.

Examples

Header, body and footer

Three regions the styled surface supplies — a divided header carrying the title and the close, a scrolling body, and a right-aligned footer for the actions. They are layout, not behaviour, which is why the Headless tab shows plain elements in their place. Drop any you do not need: a confirmation has no body worth the name. Modal.Title and Modal.Description are not decoration either way — they register their generated ids as the dialog's aria-labelledby and aria-describedby, so what you write there is what a screen reader announces on open.

Density
import { Modal, ModalTrigger, ModalPortal, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalTitle, ModalDescription, ModalClose } from "@/components/ui/modal";import { Close } from "@primitiv-ui/icons";import { Button } from "@/components/ui/button";import { Field, FieldLabel } from "@/components/ui/field";import { Input } from "@/components/ui/input";
<Modal>  <ModalTrigger asChild>    <Button>Edit profile</Button>  </ModalTrigger>  <ModalPortal>    <ModalContent>      <ModalHeader>        <ModalTitle>Edit profile</ModalTitle>        <ModalClose asChild>          <Button variant="ghost" size="sm" aria-label="Close">            <Close />          </Button>        </ModalClose>      </ModalHeader>      <ModalBody>        <ModalDescription>Changes apply to every workspace.</ModalDescription>        <Field>          <FieldLabel>Display name</FieldLabel>          <Input defaultValue="Ada Lovelace" />        </Field>      </ModalBody>      <ModalFooter>        <ModalClose asChild>          <Button variant="secondary">Cancel</Button>        </ModalClose>        <Button>Save changes</Button>      </ModalFooter>    </ModalContent>  </ModalPortal></Modal>

Sizes and density

Four widths, set on Modal.Content rather than on the root — the root renders no element of its own, so there is nothing there to size. sm is a confirmation, xl is a work surface, and each rescales again with the nearest data-density ancestor. One trap worth knowing: Modal.Portal renders into document.body by default, so a dialog escapes a data-density set on a section rather than on the document. Pass container to portal it somewhere that inherits what you meant — which is exactly what these previews do, or the density control above would do nothing.

Density
import { Modal, ModalPortal, ModalContent, ModalTitle } from "@/components/ui/modal";
<div data-density="comfortable">  <ModalContent size="sm">...</ModalContent>  <ModalContent size="md">...</ModalContent>  <ModalContent size="lg">...</ModalContent>  <ModalContent size="xl">...</ModalContent></div>

Controlled

Pass open and onOpenChange and the parent owns the flag — needed whenever something other than the trigger decides: a route change, a save that succeeded, a queue of dialogs. onOpenChange fires for every route out — the trigger, Modal.Close, Escape and a backdrop click alike — so one handler is enough. The uncontrolled form (defaultOpen, or nothing at all) is the default, and is what every other example here uses.

Density
import { Modal, ModalPortal, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalTitle, ModalDescription } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
const [open, setOpen] = useState(false);
<Button onClick={() => setOpen(true)}>Publish</Button>
<Modal open={open} onOpenChange={setOpen}>  <ModalPortal>    <ModalContent size="sm">      <ModalHeader>        <ModalTitle>Publish release</ModalTitle>      </ModalHeader>      <ModalBody>        <ModalDescription>This makes v2.1 available to everyone.</ModalDescription>      </ModalBody>      <ModalFooter>        <Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>        <Button onClick={() => { publish(); setOpen(false); }}>Publish</Button>      </ModalFooter>    </ModalContent>  </ModalPortal></Modal>

Vetoing dismissal

onEscapeKeyDown and onPointerDownOutside fire on the two gestures the browser drives itself — the dialog's native cancel event, and a pointer landing on the ::backdrop. Both hand you the raw native event, so event.preventDefault() keeps the dialog open. Use it for unsaved work, and keep an explicit way out on screen: a dialog that swallows Escape and offers no exit is a keyboard trap.

Density
import { Modal, ModalContent } from "@/components/ui/modal";
<ModalContent  onEscapeKeyDown={(event) => {    if (isDirty) event.preventDefault();  }}  onPointerDownOutside={(event) => {    if (isDirty) event.preventDefault();  }}>  ...</ModalContent>

Animating the backdrop

Modal.Overlay is optional — the native ::backdrop already dims the page, and most dialogs should not render one. Add it only to animate or restyle that layer, since it is an ordinary element you can transition. Pair it with forceMount on both the portal and the overlay so the subtree survives the close long enough for an exit animation keyed off data-state="closed" to play. Scroll-lock is deliberately not shipped: it is one line of your own CSS, html:has(dialog[open]) { overflow: hidden; }.

Density
import { Modal, ModalTrigger, ModalPortal, ModalOverlay, ModalContent } from "@/components/ui/modal";import { Button } from "@/components/ui/button";
<Modal>  <ModalTrigger asChild>    <Button>Open with a fading backdrop</Button>  </ModalTrigger>  <ModalPortal forceMount>    <ModalOverlay forceMount />    <ModalContent>...</ModalContent>  </ModalPortal></Modal>
/* Your stylesheet -- the overlay publishes data-state. */.primitiv-modal__overlay[data-state="open"] { opacity: 1; }.primitiv-modal__overlay[data-state="closed"] { opacity: 0; }