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
Preview
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><ModalTriggerasChild><Button>Open dialog</Button></ModalTrigger><ModalPortal><ModalContentsize="md"><ModalHeader><ModalTitle>Payment details</ModalTitle><ModalCloseasChild><Buttonvariant="ghost"size="sm"aria-label="Close"><Close/></Button></ModalClose></ModalHeader><ModalBody><ModalDescription> We only charge the card once the order ships.</ModalDescription></ModalBody><ModalFooter><ModalCloseasChild><Buttonvariant="secondary">Cancel</Button></ModalClose><Button>Confirm</Button></ModalFooter></ModalContent></ModalPortal></Modal>
import{Modal}from"@primitiv-ui/react";import{Close}from"@primitiv-ui/icons";<Modal.Root><Modal.Trigger>Open dialog</Modal.Trigger><Modal.Portal><Modal.Content><div><Modal.Title>Payment details</Modal.Title><Modal.Closearia-label="Close"><Close/></Modal.Close></div><div><Modal.Description> We only charge the card once the order ships.</Modal.Description></div><div><Modal.Close>Cancel</Modal.Close><button>Confirm</button></div></Modal.Content></Modal.Portal></Modal.Root>
Density is set by a data-density ancestor — the Context system, not a Modal prop.
Installation
npx primitiv add modal
pnpm dlx primitiv add modal
yarn dlx primitiv add modal
bunx 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
Eight parts in @primitiv-ui/react. The styled copy adds three more — Header, Body and Footer — which are pure layout, so under the Headless tab they are your own elements. Overlay is absent from both trees on purpose: the native <dialog> paints its own ::backdrop, and adding one is an opt-in for animating that layer.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Modal.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The modal's sub-components (Trigger, Portal, Overlay, Content, ...).
defaultOpen
boolean
false
headless
Whether 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) => void
—
headless
Called 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.
open
boolean
—
headless
Forbidden in uncontrolled mode — use defaultOpen instead.
The current open state. Must be kept in sync by the parent via onOpenChange.
ref
Ref<ModalImperativeApi>
—
headless
Ref receiving the imperative ModalImperativeApiopen/close.
Modal.Trigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
Prop
Type
Default
From
Description
children
ReactNode
—
headless
The dialog subtree to portal — typically Modal.Overlay + Modal.Content.
container
HTMLElement
document.body
headless
Target element to portal into.
forceMount
boolean
false
headless
Keep 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
forceMount
boolean
false
headless
Keep 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.
Prop
Type
Default
From
Description
onEscapeKeyDown
(event: Event) => void
—
headless
Fires 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) => void
—
headless
Fires 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"
md
styled
Dialog 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.
Modal.Footer
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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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
22 CSS custom properties on .primitiv-modal — mode-agnostic. These names are the stable surface; the values are not.
While the dialog is open. The background is inert — not merely hidden: showModal() puts the dialog in the top layer and the browser removes everything behind it from the tab order and from the accessibility tree, so there is no key that reaches the page underneath.
Key
Behaviour
Tab / Shift+Tab
Move through the dialog's focusable content, wrapping at each end.
Escape
Close the dialog and return focus to the trigger. onEscapeKeyDown can veto it.
Enter / Space
Activate the focused control — the platform's, since every part is a real <button>.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
ModalOverlay
className:.primitiv-modal__overlay
Attribute
Value
When
data-state
open | closed
open / closed
Modal.Overlay
Attribute
Value
When
data-state
open | closed
open / closed
ModalContent
className:.primitiv-modal
Attribute
Value
When
data-state
open | closed
open / closed
Modal.Content
Attribute
Value
When
data-state
open | closed
open / 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
Every example below reacts to the density control. Currently showing Styled mode.
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.
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><ModalTriggerasChild><Button>Edit profile</Button></ModalTrigger><ModalPortal><ModalContent><ModalHeader><ModalTitle>Edit profile</ModalTitle><ModalCloseasChild><Buttonvariant="ghost"size="sm"aria-label="Close"><Close/></Button></ModalClose></ModalHeader><ModalBody><ModalDescription>Changes apply to every workspace.</ModalDescription><Field><FieldLabel>Display name</FieldLabel><InputdefaultValue="Ada Lovelace"/></Field></ModalBody><ModalFooter><ModalCloseasChild><Buttonvariant="secondary">Cancel</Button></ModalClose><Button>Save changes</Button></ModalFooter></ModalContent></ModalPortal></Modal>
import{Modal}from"@primitiv-ui/react";import{Close}from"@primitiv-ui/icons";import{Field}from"@primitiv-ui/react";import{Input}from"@primitiv-ui/react";<Modal.Root><Modal.Trigger>Edit profile</Modal.Trigger><Modal.Portal><Modal.Content><div><Modal.Title>Edit profile</Modal.Title><Modal.Closearia-label="Close"><Close/></Modal.Close></div><div><Modal.Description>Changes apply to every workspace.</Modal.Description><Field><Field.Label>Display name</Field.Label><InputdefaultValue="Ada Lovelace"/></Field></div><div><Modal.Close>Cancel</Modal.Close><button>Save changes</button></div></Modal.Content></Modal.Portal></Modal.Root>
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.
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.
import{Modal,ModalPortal,ModalContent,ModalHeader,ModalBody,ModalFooter,ModalTitle,ModalDescription}from"@/components/ui/modal";import{Button}from"@/components/ui/button";const[open, setOpen]=useState(false);<ButtononClick={()=>setOpen(true)}>Publish</Button><Modalopen={open}onOpenChange={setOpen}><ModalPortal><ModalContentsize="sm"><ModalHeader><ModalTitle>Publish release</ModalTitle></ModalHeader><ModalBody><ModalDescription>This makes v2.1 available to everyone.</ModalDescription></ModalBody><ModalFooter><Buttonvariant="secondary"onClick={()=>setOpen(false)}>Cancel</Button><ButtononClick={()=>{publish();setOpen(false);}}>Publish</Button></ModalFooter></ModalContent></ModalPortal></Modal>
import{Modal}from"@primitiv-ui/react";const[open, setOpen]=useState(false);<buttononClick={()=>setOpen(true)}>Publish</button><Modal.Rootopen={open}onOpenChange={setOpen}><Modal.Portal><Modal.Contentsize="sm"><div><Modal.Title>Publish release</Modal.Title></div><div><Modal.Description>This makes v2.1 available to everyone.</Modal.Description></div><div><buttononClick={()=>setOpen(false)}>Cancel</button><buttononClick={()=>{publish();setOpen(false);}}>Publish</button></div></Modal.Content></Modal.Portal></Modal.Root>
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.
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; }.
import{Modal,ModalTrigger,ModalPortal,ModalOverlay,ModalContent}from"@/components/ui/modal";import{Button}from"@/components/ui/button";<Modal><ModalTriggerasChild><Button>Open with a fading backdrop</Button></ModalTrigger><ModalPortalforceMount><ModalOverlayforceMount/><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;}
import{Modal}from"@primitiv-ui/react";<Modal.Root><Modal.Trigger>Open with a fading backdrop</Modal.Trigger><Modal.PortalforceMount><Modal.OverlayforceMount/><Modal.Content>...</Modal.Content></Modal.Portal></Modal.Root>/* Your stylesheet -- the overlay publishes data-state. */.my-overlay[data-state="open"]{ opacity:1;}.my-overlay[data-state="closed"]{ opacity:0;}