Drawer
A panel that slides in from the edge of the screen, with swipe-to-dismiss.
Goal: 350 · Last close: none yet
Codeapp/demos/public/drawer.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { buttonVariants } from '@stemsui/registry/ui/button.tsx'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerSwipeArea,
DrawerTitle,
DrawerTrigger,
type DrawerSnapPoint,
} from '@stemsui/registry/ui/drawer.tsx'
export const DrawerDemo = clientEntry(import.meta.url, function DrawerDemo(handle: Handle) {
let goal = 350
let lastClose = 'none yet'
function adjust(delta: number) {
goal = Math.max(200, Math.min(400, goal + delta))
void handle.update()
}
function track(open: boolean, reason: string) {
if (open) return
lastClose = reason
void handle.update()
}
return () => (
<div class="flex flex-col items-center gap-4">
<div class="flex flex-wrap justify-center gap-2">
<Drawer showSwipeHandle onOpenChange={(open, details) => track(open, details.reason)}>
<DrawerTrigger class={buttonVariants({ variant: 'outline' })}>Open Drawer</DrawerTrigger>
<DrawerContent data-testid="bottom-drawer">
<div class="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div class="p-4 pb-0">
<div class="flex items-center justify-center gap-2">
<button
type="button"
aria-label="Decrease"
class={buttonVariants({
variant: 'outline',
size: 'icon',
class: 'size-8 shrink-0 rounded-full',
})}
disabled={goal <= 200}
mix={on('click', () => adjust(-10))}
>
−
</button>
<div class="flex-1 text-center">
<div class="text-7xl font-bold tracking-tighter">{goal}</div>
<div class="text-[0.70rem] text-muted-foreground uppercase">Calories/day</div>
</div>
<button
type="button"
aria-label="Increase"
class={buttonVariants({
variant: 'outline',
size: 'icon',
class: 'size-8 shrink-0 rounded-full',
})}
disabled={goal >= 400}
mix={on('click', () => adjust(10))}
>
+
</button>
</div>
<div class="mt-3 flex h-[120px] items-end gap-1">
{[60, 80, 45, 90, 70, 100, 55, 85, 65, 95, 75, 50].map((height) => (
<div
class="flex-1 rounded-t-sm bg-primary/80"
style={{ height: `${(height * goal) / 400}%` }}
/>
))}
</div>
</div>
<DrawerFooter>
<DrawerClose class={buttonVariants()}>Submit</DrawerClose>
<DrawerClose class={buttonVariants({ variant: 'outline' })}>Cancel</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
<Drawer
swipeDirection="right"
onOpenChange={(open, details) => track(open, details.reason)}
>
<DrawerTrigger class={buttonVariants({ variant: 'outline' })}>Open Right</DrawerTrigger>
<DrawerContent data-testid="right-drawer">
<DrawerHeader>
<DrawerTitle>Notifications</DrawerTitle>
<DrawerDescription>
Swipe right, press Escape, or click outside to close.
</DrawerDescription>
</DrawerHeader>
<div class="flex flex-col gap-2 p-4 text-sm">
<p>You have 3 unread messages.</p>
<p class="text-muted-foreground">Your weekly summary is ready.</p>
</div>
<DrawerFooter>
<DrawerClose class={buttonVariants({ variant: 'outline' })}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<SnapPointsExample />
</div>
<SwipeAreaExample />
<p class="text-sm text-muted-foreground" data-testid="drawer-result">
Goal: <span class="font-medium text-foreground">{goal}</span> · Last close:{' '}
<span class="font-medium text-foreground">{lastClose}</span>
</p>
</div>
)
})
const snapPoints: DrawerSnapPoint[] = ['220px', 1]
/** A bottom sheet that rests at a 220px peek and expands to full height. */
function SnapPointsExample(handle: Handle) {
let snapPoint: DrawerSnapPoint | null = snapPoints[0]
function setSnapPoint(next: DrawerSnapPoint | null) {
snapPoint = next
void handle.update()
}
return () => (
<Drawer
showSwipeHandle
snapPoints={snapPoints}
snapPoint={snapPoint}
onSnapPointChange={(next) => setSnapPoint(next)}
>
<DrawerTrigger class={buttonVariants({ variant: 'outline' })}>Snap Points</DrawerTrigger>
<DrawerContent data-testid="snap-drawer">
<DrawerHeader>
<DrawerTitle>Recent activity</DrawerTitle>
<DrawerDescription>
Drag the sheet up to expand it, or down to collapse and close it.
</DrawerDescription>
</DrawerHeader>
<div class="flex justify-center gap-2 p-4 pb-2">
{/* Keyboard alternative to dragging: set the controlled snap point. */}
<button
type="button"
class={buttonVariants({ variant: 'outline', size: 'sm' })}
disabled={snapPoint === 1}
mix={on('click', () => setSnapPoint(1))}
>
Expand
</button>
<button
type="button"
class={buttonVariants({ variant: 'outline', size: 'sm' })}
disabled={snapPoint === snapPoints[0]}
mix={on('click', () => setSnapPoint(snapPoints[0]))}
>
Collapse
</button>
</div>
<p class="px-4 text-center text-xs text-muted-foreground" data-testid="snap-point">
Snap point: {String(snapPoint)}
</p>
<ul class="flex min-h-0 flex-col gap-2 overflow-y-auto p-4">
{Array.from({ length: 12 }, (_, index) => (
<li class="rounded-md border p-3 text-sm">
<div class="font-medium">Update {index + 1}</div>
<div class="text-muted-foreground">Synced {index + 2} minutes ago</div>
</li>
))}
</ul>
</DrawerContent>
</Drawer>
)
}
/** A right-hand drawer opened by swiping in from the edge of a box. */
function SwipeAreaExample() {
return () => (
<Drawer swipeDirection="right">
<div class="relative flex h-40 w-full max-w-md items-center justify-center overflow-hidden rounded-lg border border-dashed text-sm text-muted-foreground">
<span class="pr-10">Swipe left from the right edge of this box.</span>
<DrawerSwipeArea
data-testid="drawer-swipe-area"
class="absolute inset-y-0 right-0 w-8 border-l-2 border-dashed border-primary/40 bg-primary/5 data-swiping:bg-primary/10"
/>
</div>
<DrawerContent data-testid="swipe-area-drawer">
<DrawerHeader>
<DrawerTitle>Library</DrawerTitle>
<DrawerDescription>Opened by swiping in from the edge.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose class={buttonVariants({ variant: 'outline' })}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
Installation
npx shadcn add @stemsui/drawerRequires the one-time setup. Behavior: stems-ui/drawer.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/drawer
drawer is a dialog that slides in from an edge of the screen and can be swiped away. It is
built on the stems-ui/dialog engine, so it uses a native <dialog> (modal by default) and gets
the same dismissal, focus, scroll-lock, and nesting behavior. On top of that it adds pointer-event
swipe-to-dismiss, snap points, and swipe-to-open, with Base UI's attributes and CSS variables.
Base UI equivalent: Drawer.
Usage
import * as drawer from 'stems-ui/drawer'
function MoveGoal() {
return () => (
<drawer.Context swipeDirection="down">
<button mix={drawer.trigger()}>Open drawer</button>
<dialog
class="inset-x-0 top-auto bottom-0 m-0 max-w-none w-full open:flex flex-col backdrop:bg-black/10 backdrop:opacity-[calc(1-var(--drawer-swipe-progress))] transform-[translateY(var(--drawer-swipe-movement-y))] transition-transform duration-450 data-swiping:duration-0 data-starting-style:translate-y-full data-ending-style:translate-y-full data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)]"
mix={drawer.popup()}
>
<div class="h-6 cursor-grab" aria-hidden="true" />
<div mix={drawer.content()}>
<h2 mix={drawer.title()}>Move goal</h2>
<p mix={drawer.description()}>Set your daily activity goal.</p>
<button mix={drawer.close()}>Close</button>
</div>
</dialog>
</drawer.Context>
)
}
Position the popup with CSS so that it matches swipeDirection. A <dialog> shown with
showModal() gets UA insets and max sizes (inset: 0; margin: auto; max-width/height), so reset
the ones that don't fit, for example top-auto m-0 max-w-none for a bottom sheet.
stems-ui/drawer
Context: takes everydialog.Contextprop (open,defaultOpen,onOpenChange(open, details),onOpenChangeComplete,modal,disablePointerDismissal,actionsRef), plus:swipeDirection:'up' | 'down' | 'left' | 'right'. The direction a swipe dismisses the drawer in. Defaults to'down'.snapPoints:DrawerSnapPoint[]. Heights a vertical drawer rests at (see Snap points). Ignored for'left'/'right'drawers.snapPoint/defaultSnapPoint/onSnapPointChange(snapPoint, details): the active snap point, controlled or uncontrolled.defaultSnapPointdefaults tosnapPoints[0].detailshasreason('swipe', or the close reason when closing resets the point),event, andcancel().snapToSequentialPoints: whentrue, a fast release moves at most one point instead of travelling further with the release velocity. Defaults tofalse.
trigger(options?),title(),description(),close(options?): same as indialog.popup(options?): everythingdialog.popup()does (initialFocus,finalFocus, state attributes,--nested-dialogs), plus the swipe gesture and the hooks below. It also setstouch-action: pan-x(orpan-yfor horizontal drawers), so touch drags along the swipe axis reach the drawer. Scroll containers inside the drawer still scroll.backdrop(options?): the dialog backdrop element plus--drawer-swipe-progress.content(): a region where mouse drags select text instead of swiping, as in Base UI. Touch and pen drags still swipe.swipeArea(options?): an element (usually an invisible strip along a screen edge) where a swipe opens the drawer. Options:swipeDirection(the open direction; defaults to the opposite of the Context'sswipeDirection) anddisabled. Put it insideContext, outside the popup. See Swipe to open.Provider,indent(),indentBackground(): optional app-level parts. While any drawer insideProvideris open,indent()andindentBackground()getdata-active. During a top-level drawer swipe,indent()also gets--drawer-swipe-progress.onDrawerChange(handler): listener mixin forDrawerChangeEvent(stems:drawer-change, bubbles, hasopenandreason). A swipe dismissal or swipe-area open/close has reason'swipe'.onDrawerSnapPointChange(handler): listener mixin forDrawerSnapPointChangeEvent(stems:drawer-snap-point-change, dispatched from the popup, bubbles, hassnapPointandreason).
Swipe behavior
- A press on the popup starts tracking. These presses are ignored:
- presses on
input,textarea,select, or[contenteditable]; - presses inside
[data-swipe-ignore]. With a value ofxory, only touch drags along that axis are ignored. This is Base UI'sdata-base-ui-swipe-ignore. - mouse presses inside
content().
- presses on
- The drag becomes a swipe once it moves 6px along the swipe axis. It does not become a swipe if
it moves more sideways than along the axis, or if a scroll container under the pointer can
still scroll that way. Once the drag is a swipe, the pointer is captured,
data-swipingis set, and the click that follows the drag is suppressed. - During the swipe,
--drawer-swipe-movement-x/-yfollow the pointer. Movement against the dismiss direction is damped.--drawer-swipe-progress(0–1) is written to the popup, tobackdrop()elements, and to the popups of parent drawers. - When the pointer is released, the drawer is dismissed in either of these cases:
- the swipe moved at least 40% of the popup's size;
- the swipe moved at least 16px and was released faster than 0.5px/ms.
- On dismissal,
--drawer-swipe-strength(0.1–1) is set from the remaining distance and the release speed,data-swipe-dismissis set, and the drawer closes with reason'swipe'. Otherwise the variables reset to0and CSS animates the drawer back. IfonOpenChangecancels the dismissal, the drawer also snaps back. pointercancel, for example when the browser takes over a touch to scroll, ends the swipe without dismissing.
Snap points
<drawer.Context snapPoints={['148px', 1]}>
…
<dialog
class="… h-[calc(100dvh-2rem)] transform-[translateY(calc(var(--drawer-snap-point-offset)+var(--drawer-swipe-movement-y)))]"
mix={drawer.popup()}
>
- A number from 0 to 1 is a fraction of the viewport height (
documentElement.clientHeight), a number above 1 is pixels, and a string ispxorrem('148px','30rem'). Heights are clamped to the popup's height, so1means "fully shown". Points that resolve within 1px of a later point are dropped. - Snap points apply to
'down'and'up'drawers. The popup is measured once it's shown (and again when it or the window resizes), and--drawer-snap-point-offsetis set to how far the popup must move toward its closed edge to show only the active point's height (negative for'up'). Include it in the popup's transform. Before measuring, and in server HTML, it's0px. - Dragging works in both directions: the popup follows the pointer, and is damped only past its fully open position. A drag toward "more open" on content that can still scroll (once the drawer is fully open) scrolls instead.
- On release, the target is the drag position plus
velocity × 300px(velocity ≥ 0.5px/ms, capped at 4px/ms; skipped withsnapToSequentialPoints, which instead advances one point on a fast release). The drawer settles on the closest point, or closes if the closed position is closer. A release toward the dismiss direction faster than 0.5px/ms closes from any point. These thresholds are Base UI's. - Closing by swipe first sets the snap point to
null(reason'swipe'), then closes. Any close resets the snap point todefaultSnapPoint. Cancelling either change keeps the drawer where it was. --drawer-swipe-progressis 0 at the most open point and 1 at the next one, so a backdrop that fades with progress fades out at lower points. With one snap point it behaves as without snap points.data-expandedis set when the active point is1, or resolves to the popup's full height.- Base UI has no keyboard interaction for snap points. Control
snapPointfrom buttons inside the drawer to offer one (the docs demo has "Expand" / "Collapse").
Swipe to open
<drawer.Context swipeDirection="right">
<div class="fixed inset-y-0 right-0 w-6" mix={drawer.swipeArea()} />
<dialog mix={drawer.popup()}>…</dialog>
</drawer.Context>
- A press on the area starts tracking (mouse presses call
preventDefault()so they don't select text). Once the pointer moves 6px in the open direction, more along the axis than across it, the drawer opens with reason'swipe', anddata-swipingis set on the area and the popup.--drawer-swipe-movement-x/-ythen keep the popup under the pointer (damped past fully open), and--drawer-swipe-progressgoes from 1 to 0. - The gesture is followed with window listeners, not pointer capture, because a modal drawer makes the area inert as it opens.
- On release, the drawer stays open if the drag covered half the popup's size or was released
faster than 0.1px/ms in the open direction; otherwise it closes again (reason
'swipe'). - The area counts as part of the drawer for outside presses, and the click that the release produces is swallowed, so it doesn't dismiss the drawer it just opened.
- While the drawer is open (and not being swiped open) the area has
pointer-events: none. It also setstouch-actionto allow panning across its axis only, androle="presentation"witharia-hidden="true": it's a pointer shortcut, so keep atrigger()for keyboard and screen reader users.
Styling hooks
| Part | Attribute / variable |
|---|---|
trigger() | aria-expanded, data-popup-open, data-disabled |
popup() | :open, data-open / data-closed, data-starting-style / data-ending-style, data-nested, data-nested-dialog-open, data-swipe-direction, data-swiping, data-swipe-dismiss, data-nested-drawer-open, data-nested-drawer-swiping, data-expanded, --drawer-swipe-movement-x, --drawer-swipe-movement-y, --drawer-swipe-progress, --drawer-swipe-strength, --drawer-snap-point-offset, --drawer-height, --nested-drawers, --nested-dialogs, --drawer-frontmost-height |
swipeArea() | data-open / data-closed, data-swiping, data-disabled, data-swipe-direction (the open direction) |
backdrop() | hidden, data-open / data-closed, data-starting-style / data-ending-style, --drawer-swipe-progress |
indent() / indentBackground() | data-active, --drawer-swipe-progress (indent() only during swipes) |
close() | data-disabled |
--drawer-frontmost-height is the height of the frontmost open drawer. It is written to the
drawers behind it in a nested stack, measured when a drawer in the stack opens or closes.
--drawer-height is the popup's measured height. As in Base UI it is only set while it must
stay fixed: while a nested drawer is open (so the stack can collapse to it) and while the drawer
is closing. Otherwise it's unset, so use var(--drawer-height, auto).
With modal: true, ::backdrop inherits the popup's --drawer-swipe-progress, so a backdrop
can fade during the swipe without a separate element:
backdrop:opacity-[calc(1-var(--drawer-swipe-progress))].
Detached triggers
drawer.createHandle() works like dialog.createHandle():
pass the handle to <drawer.Context handle> and to drawer.trigger({ handle, payload })
anywhere in the document; the Context's children may be a function of { payload }. The
handle has isOpen, open(triggerId?), openWithPayload(payload), and close(), and the
Context takes triggerId / defaultTriggerId.
Not yet supported
VirtualKeyboardProviderand--drawer-keyboard-inset.- Base UI's
Viewportpart. The<dialog>is positioned directly in the top layer, and snap points resolve against the document viewport.