A modal bottom-sheet overlay with drag-to-dismiss, optional snap points, virtual-keyboard
awareness, and nesting. Built on the same Floating UI infrastructure as Dialog (portal, focus
trap, scroll lock, dismiss, FloatingTree nesting, enter/exit transitions) with a hand-rolled
pointer/transform drag engine layered on top.
The headless layer emits raw CSS custom properties and data-cl-* attributes and ships zero
CSS. The styled (mosaic) layer composes the actual transform / opacity / calc() chains from
those inputs.
- A sheet that slides up from the bottom edge and is dismissed by swiping down.
- Mobile-first flows where snap points (peek / half / full) make sense.
- Prefer
Dialogfor centered modals that are not anchored to the bottom and have no drag gesture.
import { Drawer } from '@clerk/headless/drawer';
<Drawer.Root>
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Backdrop />
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Handle />
<Drawer.Title>Sheet title</Drawer.Title>
<Drawer.Description>Optional description.</Drawer.Description>
<p>Sheet content.</p>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>;const [open, setOpen] = useState(false);
<Drawer.Root
open={open}
onOpenChange={setOpen}
>
{/* ... */}
</Drawer.Root>;A trigger rendered outside Drawer.Root can drive it through a shared handle:
const handle = createDrawerHandle();
<>
<Drawer.Trigger handle={handle}>Open from anywhere</Drawer.Trigger>
<Drawer.Root handle={handle}>{/* ... */}</Drawer.Root>
</>;handle.open() / handle.close() / handle.toggle() work imperatively; handle.isOpen and
handle.subscribe(cb) make it useSyncExternalStore-compatible.
// Ascending fractions of the viewport the sheet can rest at; 1 = full height.
<Drawer.Root
snapPoints={[0.4, 0.75, 1]}
defaultActiveSnapPoint={0}
>
{/* ... */}
</Drawer.Root>Control the active point with activeSnapPoint / onActiveSnapPointChange. A slow release settles to
the nearest point; a fast flick steps one point (up = larger, down = smaller, or dismiss from the
first point when dismissible). An uncontrolled drawer returns to its default snap point when it
closes, so the next open starts fresh. Dragging past the fully-open edge is square-root damped so the
sheet resists overshooting.
// Only a press starting on Drawer.Handle initiates the drag; the body scrolls normally.
<Drawer.Root handleOnly>{/* ... */}</Drawer.Root>| Part | Default Element | Description |
|---|---|---|
Drawer.Root |
— | Root context provider; owns open/drag/snap/nesting state |
Drawer.Trigger |
<button> |
Opens the drawer (in-tree, or via a detached handle) |
Drawer.Portal |
— | Portals children (defaults to document.body) |
Drawer.Backdrop |
<div> |
Semi-transparent overlay surface |
Drawer.Viewport |
<div> |
Fixed full-viewport container; owns scroll lock |
Drawer.Popup |
<div> |
The sheet (role="dialog"); hosts the drag gesture + focus trap |
Drawer.Handle |
<div> |
Visual drag grip; the hit-test target when handleOnly |
Drawer.Title |
<h2> |
Heading, wired to aria-labelledby |
Drawer.Description |
<p> |
Description, wired to aria-describedby |
Drawer.Close |
<button> |
Closes the drawer on click |
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
— | Controlled open state |
defaultOpen |
boolean |
false |
Initial open state (uncontrolled) |
onOpenChange |
(open: boolean) => void |
— | Called when open state changes |
modal |
boolean |
true |
Traps focus and blocks page interaction |
dismissible |
boolean |
true |
Allow drag / outside-press / Escape to close |
handleOnly |
boolean |
false |
Only Drawer.Handle starts a drag |
handle |
DrawerHandle |
— | Shared handle for a detached trigger |
snapPoints |
number[] |
— | Ascending viewport fractions (0..1) to rest at |
activeSnapPoint |
number |
— | Controlled active snap index |
defaultActiveSnapPoint |
number |
last | Uncontrolled initial active snap index |
onActiveSnapPointChange |
(index: number) => void |
— | Called when the active snap index changes |
repositionInputs |
boolean |
true |
Keep a focused field above the virtual keyboard |
autoFocus |
boolean |
false |
Move focus into the sheet on open (see notes) |
| Prop | Type | Description |
|---|---|---|
handle |
DrawerHandle |
Drive a detached handle instead of the surrounding Drawer.Root |
| Prop | Type | Default | Description |
|---|---|---|---|
lockScroll |
boolean |
true |
Prevents body scroll while open |
Other parts accept standard HTML attributes plus the render prop.
| Key | Action |
|---|---|
Escape |
Closes the drawer (closes a nested child overlay first, if open) |
Tab |
Cycles focus within the sheet (modal mode) |
The headless parts emit raw inputs only — the styled layer composes them. The high-frequency vars
(swipe-movement-y, snap-point-offset, swipe-progress) are registered as non-inheriting custom
properties via registerDrawerCssVars() (a no-op where CSS.registerProperty is unavailable).
| Variable | Written by | Meaning |
|---|---|---|
--cl-drawer-swipe-movement-y |
drag engine | px live drag delta on the Y axis (0 at rest) |
--cl-drawer-swipe-progress |
drag engine | 0..1 dismiss progress (drives backdrop fade) |
--cl-drawer-snap-point-offset |
snap layer | px resting translateY of the active snap point |
--cl-drawer-swipe-strength |
drag engine | 0.1..1 from release velocity (scales exit speed) |
--cl-drawer-nested-drawers |
nesting layer | count of open nested children |
--cl-drawer-nested-drag-progress |
nesting layer | 0..1 dismiss progress of the dragged nested child (drives the parent's live scale-in) |
| Attribute | Applies to | Meaning |
|---|---|---|
data-cl-open / data-cl-closed |
Trigger, Backdrop, Viewport, Popup | Open state |
data-cl-starting-style / data-cl-ending-style |
Backdrop, Viewport, Popup | Enter / exit transition phase |
data-cl-swiping |
Popup, Backdrop | A drag is in progress |
data-cl-snap |
Popup | Active snap index |
data-cl-expanded |
Popup | Resting at the full-height snap |
data-cl-nested |
Popup | This drawer is itself nested |
data-cl-nested-drawer-open |
Popup | A nested child is open |
data-cl-nested-drawer-swiping |
Popup | A nested child is being dragged |
data-cl-drawer-handle |
Handle | Grip / handleOnly hit-test |
data-cl-drawer-no-drag |
(consumer-set) | Opt a subtree out of dragging |
Slot identity (data-cl-slot) is applied by the styled (mosaic) layer, not by the headless parts.
autoFocusdefaults tofalse(unlikeDialog). Opening on touch should not move focus to an input and summon the keyboard, so focus goes to the sheet container by default. SetautoFocusto move focus to the first field.Drawer.Handleis presentational (no ARIA role). Keyboard users dismiss viaEscape/Drawer.Close; add your ownrole/aria-*(via props orrender) if you need it announced.- Drag never hijacks form controls.
shouldDragexcludes<select>, nativerangeinputs,[role="slider"]thumbs,[data-cl-drawer-no-drag]subtrees, active text selections (DOM,contenteditable, and focusedinput/textareaselection handles), and inner content scrolled away from the top. Usedata-cl-drawer-no-dragfor any other custom draggable. - Nested overlays don't dismiss the drawer. A
Select/Autocomplete/Menuopened inside the sheet joins the sameFloatingTree, so a press in its portal counts as inside. - Nested drawers stack automatically (shared
FloatingTree); the parent receivesdata-cl-nested-drawer-openand--cl-drawer-nested-drawersso the styled layer can scale it back. While a nested child is dragged, the parent also receivesdata-cl-nested-drawer-swipingand a live--cl-drawer-nested-drag-progress(0 = child at rest, 1 = child fully dismissed), so the styled layer can interpolate the parent's scale back in as the child is dragged down (vaul-style coupling).
- Directions other than bottom (top / left / right).
- Full iOS keyboard hardening (the pre-focus
translateYtrick and afocusoverride) plus snap-point-aware keyboard offsets and flick-focus suppression. --cl-drawer-frontmost-heightpeek math (the live scale-back interpolation via--cl-drawer-nested-drag-progressis built; frontmost-height-based peek offsets are not).- Resize-driven recomputation of snap offsets.
- Per-trigger payloads on detached handles (
createDrawerHandle<T>()+ a render-propDrawer.Root). onActiveSnapPointChangeevent details (the changereason, e.g. close-press vs. drag).- The mosaic styled layer and a playground.
- Popup:
role="dialog",aria-labelledby(from Title),aria-describedby(from Description). - Trigger:
aria-expanded,aria-haspopup="dialog",aria-controls(in-tree).