Density is set by a data-density ancestor — the Context system, not a Drawer prop.
Installation
npx primitiv add drawer
pnpm dlx primitiv add drawer
yarn dlx primitiv add drawer
bunx primitiv add drawer
Import
import{Drawer}from"@/components/ui/drawer";
Copied into your project as .primitiv-drawer — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Drawer.Root owns the open state; Drawer.Trigger opens it; Drawer.Portal holds the Drawer.Overlay (backdrop) and the Drawer.Content (the sliding <dialog>, taking width + side). Inside, Drawer.Header / Drawer.Body / Drawer.Footer are the copied file's layout regions — Drawer.Header pairs a Drawer.Title with the close, Drawer.Body scrolls, Drawer.Footer holds the actions.
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Drawer.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.
Drawer.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.
Drawer.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.
Drawer.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.
Drawer.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.
ref
Ref<HTMLDialogElement>
—
headless
Allows getting a ref to the component instance.
Once the component unmounts, React will set ref.current to null
(or call the ref with null if you passed a callback ref).
Ref to the underlying native <dialog> element.
side
"top" | "right" | "bottom" | "left"
"right"
headless
Which edge the drawer slides in from. Emitted verbatim as data-side
on the <dialog>, which the styled layer keys off to position and
animate the panel against that edge. Purely presentational — it does
not change any behaviour, focus, or ARIA.
width
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
The drawer's cross-axis extent — width for a left/right drawer, height for a top/bottom sheet — stepped off the size/* scale. The long axis always fills the viewport; padding stays density-driven, independent of width.
Drawer.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.
Drawer.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.
Drawer.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.
Drawer.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.
Drawer.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.
Drawer.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-drawer — mode-agnostic. These names are the stable surface; the values are not.
A drawer is a modal dialog, so it traps focus: Tab and Shift+Tab cycle within the panel and never reach the inert page behind it. Escape closes it and returns focus to the trigger; clicking the backdrop dismisses it too.
Key
Behaviour
Enter / Space
Open the drawer from the focused trigger.
Escape
Close the drawer and return focus to the trigger.
Tab
Cycle focus within the drawer — the background is inert, so focus cannot leave it.
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.
DrawerOverlay
className:.primitiv-drawer__overlay
Attribute
Value
When
data-state
open | closed
open / closed
Drawer.Overlay
Attribute
Value
When
data-state
open | closed
open / closed
DrawerContent
className:.primitiv-drawer
Attribute
Value
When
data-state
open | closed
open / closed
data-side
top | right | bottom | left
side=top / side=right / side=bottom / side=left
Drawer.Content
Attribute
Value
When
data-state
open | closed
open / closed
data-side
top | right | bottom | left
side=top / side=right / side=bottom / side=left
Accessibility
Drawer.Content is a native <dialog> opened as a modal, so the browser inerts the rest of the page for you — background controls cannot be focused or clicked while the drawer is open, no aria-hidden bookkeeping required.
Focus is trapped inside the drawer and cycles at the boundaries (Tab/Shift+Tab), and returns to the trigger on close, so a keyboard user is never stranded on an inert page behind it.
Drawer.Title names the dialog (aria-labelledby) and Drawer.Description describes it — always give a drawer a title, so a screen reader announces what opened rather than a bare panel.
Escape and a backdrop click both dismiss it, matching the platform expectation for a dialog — the same escape hatches Modal provides.
Reach for Popover instead when the content is optional and non-blocking: a drawer is modal and takes over, so use it for a focused task (filters, a form, navigation), not a passing hint.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Sides
side docks the drawer to an edge and slides it in from there: right (the default) and left for navigation and filters, bottom for a mobile action sheet, top for a notification tray. width sets the cross-axis extent — the width for left/right, the height for top/bottom. Click a trigger to open it.