Skip to content
Primitiv home
Framework
Consumption mode

Drawer

stableSource Figma

An accessible drawer — a Modal dialog built on the native <dialog> element that slides in from a screen edge (top, right, bottom, or left).

Playground

Density

Preview

Width
Side
import { Drawer, DrawerTrigger, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHeader, DrawerBody, DrawerFooter, DrawerTitle, DrawerDescription, DrawerClose } from "@/components/ui/drawer";import { Button } from "@/components/ui/button";
<Drawer>  <DrawerTrigger asChild>    <Button>Open drawer</Button>  </DrawerTrigger>  <DrawerPortal>    <DrawerOverlay />    <DrawerContent width="md" side="right">      <DrawerHeader>        <DrawerTitle>Filters</DrawerTitle>      </DrawerHeader>      <DrawerBody>{/* ... */}</DrawerBody>      <DrawerFooter>{/* ... */}</DrawerFooter>    </DrawerContent>  </DrawerPortal></Drawer>

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

Installation

npx 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>  <DrawerTrigger />  <DrawerPortal>    <DrawerOverlay />    <DrawerContent>      <DrawerHeader>        <DrawerTitle />        <DrawerClose />      </DrawerHeader>      <DrawerBody />      <DrawerFooter />    </DrawerContent>  </DrawerPortal></Drawer>

Props

Drawer.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.

Drawer.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.

Drawer.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.

Drawer.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.

Drawer.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.
refRef<HTMLDialogElement>headlessAllows 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"headlessWhich 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"mdstyledThe 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.

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.

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.

Drawer.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.

Drawer.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-drawer-surface--primitiv-drawer-fg--primitiv-drawer-shadow--primitiv-drawer-scrim--primitiv-drawer-radius--primitiv-drawer-padding-inline--primitiv-drawer-padding-block--primitiv-drawer-gap--primitiv-drawer-size--primitiv-drawer-border-width--primitiv-drawer-border-color--primitiv-drawer-divider-color--primitiv-drawer-title-color--primitiv-drawer-title-font-family--primitiv-drawer-title-font-size--primitiv-drawer-title-font-weight--primitiv-drawer-title-line-height--primitiv-drawer-description-color--primitiv-drawer-description-font-family--primitiv-drawer-description-font-size--primitiv-drawer-description-font-weight--primitiv-drawer-description-line-height

Keyboard

KeyBehaviour
Enter / SpaceOpen the drawer from the focused trigger.
EscapeClose the drawer and return focus to the trigger.
TabCycle focus within the drawer — the background is inert, so focus cannot leave it.

Data attributes

DrawerOverlay

className: .primitiv-drawer__overlay

AttributeValueWhen
data-stateopen | closedopen / closed

DrawerContent

className: .primitiv-drawer

AttributeValueWhen
data-stateopen | closedopen / closed
data-sidetop | right | bottom | leftside=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

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.

Density
import { Drawer, DrawerContent } from "@/components/ui/drawer";
<DrawerContent side="left" width="sm"></DrawerContent>