implement
Drawer

Drawer

A panel that slides in from an edge of the screen, and can be thrown back out.

import { Div, P, signal } from "@implementjs/core";
import {
	Drawer,
	DrawerClose,
	DrawerContent,
	DrawerDescription,
	DrawerTitle,
	DrawerTrigger,
} from "@/lib/components/ui/drawer";
import { Button } from "@/lib/components/ui/button";

const MIN = 60;
const MAX = 400;

export default function DrawerDemo() {
	const goal = signal(350);
	const step = (by: number) => goal.update((value) => Math.min(MAX, Math.max(MIN, value + by)));

	return Drawer(
		DrawerTrigger("Move goal"),
		DrawerContent(
			Div(
				{ class: "mx-auto flex w-full max-w-sm flex-col gap-6 p-4 pb-8" },
				Div(
					{ class: "grid gap-1.5" },
					DrawerTitle("Move goal"),
					DrawerDescription("Set your daily activity goal."),
				),
				Div(
					{ class: "flex items-center justify-center gap-6" },
					Button(
						{
							variant: "outline",
							size: "icon",
							class: "rounded-full",
							"aria-label": "Decrease goal",
							disabled: goal.bind((value) => value <= MIN),
							onClick: () => step(-10),
						},
						"−",
					),
					Div(
						{ class: "flex w-24 flex-col items-center" },
						P({ class: "text-5xl font-bold tabular-nums" }, goal),
						P({ class: "text-xs text-muted-foreground uppercase" }, "calories/day"),
					),
					Button(
						{
							variant: "outline",
							size: "icon",
							class: "rounded-full",
							"aria-label": "Increase goal",
							disabled: goal.bind((value) => value >= MAX),
							onClick: () => step(10),
						},
						"+",
					),
				),
				Div(
					{ class: "grid gap-2" },
					DrawerClose({ variant: "default" }, "Submit"),
					DrawerClose("Cancel"),
				),
			),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/drawer

jsrepo pulls button along with it, and installs @implementjs/lucide.

Usage

DrawerContent is the panel, and it brings the scrim and the portal with it: DrawerOverlay renders behind it and DrawerPortal mounts the pair on document.body, so neither is yours to place. Both stay exported for a layout that composes the panel itself out of the primitives — pairing them with the styled DrawerContent only gets you two scrims.

A grab handle is included. showHandle: false removes it, and showCloseButton: true adds an X in the corner the way dialog has one.

import {
	Drawer,
	DrawerClose,
	DrawerContent,
	DrawerDescription,
	DrawerTitle,
	DrawerTrigger,
} from "@/lib/components/ui/drawer";

Drawer(
	DrawerTrigger("Move goal"),
	DrawerContent(
		DrawerTitle("Move goal"),
		DrawerDescription("Set your daily activity goal."),
		DrawerClose({ variant: "default" }, "Submit"),
	),
);

The panel is a flex flex-col with nothing inside it, so the padding is yours. A centered column reads best on a phone and stops the panel from stretching a form across a desktop:

DrawerContent(Div({ class: "mx-auto flex w-full max-w-sm flex-col gap-6 p-4 pb-8" }, …));

Direction

direction on the root picks the edge, and every part reads it from there — the panel anchors itself, rounds the inside corners, and turns the handle sideways for a left or right drawer.

Drawer({ direction: "right" }, DrawerTrigger("Filters"), DrawerContent(…));

Every panel drags out the edge it came in on.

import { Div, P } from "@implementjs/core";
import {
	Drawer,
	DrawerContent,
	DrawerDescription,
	DrawerTitle,
	DrawerTrigger,
} from "@/lib/components/ui/drawer";
import type { DrawerDirection } from "@implementjs/primitives";

const directions: DrawerDirection[] = ["top", "right", "bottom", "left"];

function DirectionDrawer(direction: DrawerDirection) {
	return Drawer(
		{ direction },
		DrawerTrigger({ size: "sm" }, direction),
		DrawerContent(
			Div(
				{ class: "flex flex-1 flex-col justify-center gap-1.5 p-6" },
				DrawerTitle(`From the ${direction}`),
				DrawerDescription("Drag it back the way it came, or press Escape."),
			),
		),
	);
}

export default function DrawerDirectionsDemo() {
	return Div(
		{ class: "flex flex-col items-center gap-3" },
		Div({ class: "flex flex-wrap justify-center gap-2" }, ...directions.map(DirectionDrawer)),
		P({ class: "text-xs text-muted-foreground" }, "Every panel drags out the edge it came in on."),
	);
}

A bottom or top drawer caps at 85% of the viewport; a left or right one is three quarters wide up to max-w-sm. Override any of it with class.

Snap points

Give the root snapPoints and the panel gets resting positions instead of one open state. The styled content notices — under data-snap-points it fills the axis rather than capping, so the offset is what reveals part of it.

import { Div, ForEach, P, signal } from "@implementjs/core";
import {
	Drawer,
	DrawerContent,
	DrawerDescription,
	DrawerTitle,
	DrawerTrigger,
} from "@/lib/components/ui/drawer";

const releases = [
	{ version: "0.4.0", note: "Snap points, and an overlay that fades between them." },
	{ version: "0.3.2", note: "Focus returns to the trigger that opened the panel." },
	{ version: "0.3.1", note: "A drag no longer clicks whatever it started on." },
	{ version: "0.3.0", note: "Four directions, one set of offset variables." },
	{ version: "0.2.4", note: "Scroll containers keep their scroll until they reach the edge." },
	{ version: "0.2.3", note: "Rubber band past the open position instead of a hard stop." },
	{ version: "0.2.2", note: "Velocity decides the landing, not just the distance." },
	{ version: "0.2.1", note: "Escape and the scrim both dismiss." },
];

export default function DrawerSnapPointsDemo() {
	const snap = signal<number | string | null>(0.4);

	return Drawer(
		{ snapPoints: [0.4, 0.75, 1], activeSnapPoint: snap, fadeFromIndex: 1 },
		DrawerTrigger("Changelog"),
		DrawerContent(
			Div(
				{ class: "mx-auto flex w-full max-w-md flex-col gap-1.5 px-4" },
				DrawerTitle("Changelog"),
				DrawerDescription(
					snap.bind((point) =>
						point === 1 ? "Drag down to shrink it back." : "Drag up, or tap the handle, for more.",
					),
				),
			),
			Div(
				{ class: "mt-4 flex-1 overflow-y-auto overscroll-contain px-4 pb-8" },
				Div(
					{ class: "mx-auto grid w-full max-w-md gap-3" },
					ForEach(
						releases,
						(release) => release.version,
						(release) =>
							Div(
								{ class: "rounded-lg border bg-card p-3" },
								P({ class: "text-sm font-medium" }, release.bind("version")),
								P({ class: "text-sm text-muted-foreground" }, release.bind("note")),
							),
					),
				),
			),
		),
	);
}
const snap = signal<number | string | null>(0.4);

Drawer(
	{ snapPoints: [0.4, 0.75, 1], activeSnapPoint: snap },
	DrawerTrigger("Changelog"),
	DrawerContent(
		DrawerTitle("Changelog"),
		Div({ class: "flex-1 overflow-y-auto overscroll-contain" }, …),
	),
);

The scrim's opacity is --ip-drawer-fade, which the drag writes directly, so it darkens as the panel comes up and clears as it goes back down. Below fadeFromIndex — the last snap point unless you say otherwise — it stays clear, which is what lets a drawer sit at 40% without dimming what is behind it.

A scrolling region inside the panel wants overflow-y-auto overscroll-contain. The drag knows to leave it alone until it has scrolled back to the top.

Dragging

Everything about the gesture lives on the root: dismissible: false to take dismissal away, handleOnly: true to make the handle the only drag surface, closeThreshold for how far a slow drag has to go, snapToSequentialPoint to stop a hard fling from skipping snap points. See the primitive for what each one does.

Mark anything that wants the gesture for itself — a slider, a map — with data-drawer-no-drag:

DrawerContent(DrawerTitle("Layers"), Div({ "data-drawer-no-drag": "" }, MapCanvas()));

Keyboards

An on-screen keyboard covers the bottom of the panel and the panel does not move, the way an iOS sheet behaves. Everything in it lays out in the room that is left, so a field near the top stays exactly where it was — and because nothing moved, the browser never scrolls the page to chase it.

DrawerContent does that by ending its column with a spacer as tall as --ip-drawer-keyboard-inset from the primitive. A spacer rather than padding, so that reaching for class to set your own padding does not quietly take it away.

Its height cap grows by the same inset, so the 85dvh it holds the panel to is the part of the panel you can actually see. A bottom drawer with a keyboard up therefore takes the whole band above it, the way an iOS sheet does. If you set your own max-h, keep the inset in it — a flat cap squeezes the spacer back out and puts the end of your column behind the keyboard.

Put whatever must stay visible near the top of the panel, and give the part that can afford to shrink min-h-0 flex-1 overflow-y-auto.

Moving between two fields does not reflow the panel. The keyboard dips as focus is handed over, and the primitive waits that out rather than laying the panel out again for it.

Scaling the page behind

scaleBackground: true marks the document while the drawer is open and leaves the transform to your stylesheet, so the page can shrink back the way it does on iOS. Put data-drawer-wrapper on the element that should shrink and add this to your CSS:

[data-drawer-wrapper] {
	transform-origin: top;
	transition:
		scale 0.5s cubic-bezier(0.32, 0.72, 0, 1),
		border-radius 0.5s cubic-bezier(0.32, 0.72, 0, 1);
}

html[data-drawer-open] {
	background: black;
}

html[data-drawer-open] [data-drawer-wrapper] {
	overflow: hidden;
	border-radius: calc(8px * (1 - var(--ip-drawer-progress, 0)));
	scale: var(--ip-drawer-scale, 1);
}

Drawer or sheet?

Both are the dialog wearing a panel. sheet slides in and out, and that is all it does. Reach for the drawer when the gesture is the point — a phone-shaped surface you flick away, or one that rests at more than one height. On a desktop with a pointer they look the same; the difference is what a thumb can do with them.

That is why the sidebar falls back to a drawer rather than a sheet below 768px, and why a modal that has to work on both often wants to be a responsive dialog: a dialog where there is room for one, and a drawer where a thumb is what is reaching for it.

Controlling it

Pass open as a signal to drive the drawer from outside, with or without a trigger:

const open = signal(false);

Drawer({ open }, DrawerContent(DrawerTitle("Saved")));

API Reference

Every prop the styling does not consume is forwarded to the Drawer primitive, so the tables below are the whole surface — the behavior props and the styling ones together.

Drawer

The root. Owns whether the drawer is open, which edge it lives on, and where a released drag lands.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.
direction"top" | "bottom" | "left" | "right""bottom"Edge the panel is anchored to, and therefore the way it drags out.
dismissiblebooleantrueWhen false, nothing the drawer owns closes it — the drag, Escape, the scrim, and DrawerClose all stop. Drive open yourself. A drawer nested in one that closes still goes with it.
snapPoints(number | string)[]—Resting positions, least to most of the screen: a fraction of the viewport (0.5) or a length it does not enter into ("148px"). Without them the panel only opens fully.
activeSnapPointSignal<number | string | null> | number | string | nullsnapPoints[0]The snap point the panel rests at. Pass a signal to read the current one or move the panel from outside.
fadeFromIndexnumbersnapPoints.length - 1The snap point the overlay finishes fading in at. Below it the overlay is clear, so the page behind stays usable to look at.
snapToSequentialPointbooleanfalseWhen true a hard fling moves one snap point instead of skipping to the far end. For drawers where every snap point matters.
closeThresholdnumber0.25Fraction of the panel a slow drag has to cover before releasing dismisses it. A fast one dismisses on velocity alone.
scrollLockTimeoutnumber100ms after scrolling inside the panel during which a drag will not start, so the end of a scroll does not throw the drawer.
handleOnlybooleanfalseWhen true only DrawerHandle starts a drag; the rest of the panel does not.
scaleBackgroundbooleanfalseMarks the document with data-drawer-open, --ip-drawer-scale, and --ip-drawer-progress while an outermost drawer is open, so a [data-drawer-wrapper] can scale the page back behind it.
preventScrollbooleantrueWhen true, the page behind cannot scroll while the drawer is open. The panel can still scroll if you give it overflow.
onDrag(progress: number) => void—Runs on every drag frame with how far the panel has been pulled from its resting position, 0 to 1.
onRelease(open: boolean) => void—Runs when a drag ends, with whether the drawer stays open.

DrawerTrigger

Toggles the drawer open and closed. Clicking a different trigger keeps it open and remembers that button for focus return. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""outline"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"The button's height and padding scale. Also set as data-size.
defaultbooleanfalseWhen the drawer starts open, return focus to this trigger instead of the first one in the tree.
Data attributeValue
[data-drawer-trigger]Present
[data-state]"open" | "closed"

DrawerOverlay

The scrim behind the panel. Style its opacity against --ip-drawer-fade so it follows the drag; the primitive does not hide it for you. Styled as a fixed scrim whose opacity is --ip-drawer-fade, so it follows the drag; a nested drawer's scrim renders transparent so the stack does not darken twice. DrawerContent renders one for you — this export is for composing a panel of your own. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-drawer-overlay]Present
[data-state]"open" | "closed"
[data-drawer-direction]"top" | "bottom" | "left" | "right"
[data-dragging]Present while a drag is in progress
[data-snap-points]Present when the root was given snap points
[data-faded-in]Present when the panel rests at or above fadeFromIndex
[data-nested]Present when this drawer is nested in another
[data-nested-open]Present when a nested drawer is open
[data-nested-count]Number of open nested drawers
[data-nested-level]Depth in the stack; 0 is the outermost drawer
CSS variableDescription
--ip-drawer-fadeThe scrim's opacity, 1 covering the page and 0 clear. Follows the drag, and stays 0 while the panel rests below fadeFromIndex.
--ip-drawer-progressHow far the drag has pulled the panel from its resting position, 0 to 1.

DrawerContent

The panel. Sets role="dialog" and aria-modal, and takes the drag. Position it against the edge named by direction and translate it with the offset variables; the primitive does not hide or place it for you. Styled from the root's direction: anchored to that edge, rounded on the inside corners, and translated by the offset variables so the drag and the active snap point move it. Given snap points it fills the axis instead of capping at 85%. Renders its own DrawerOverlay inside a DrawerPortal, so neither has to be placed by hand. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
showHandlebooleantrueRenders a DrawerHandle as the panel's first child.
showCloseButtonbooleanfalseRenders a DrawerClose in the top right corner. Off by default — the handle, the scrim, and Escape are the affordances.
overlayDrawerOverlayProps{}Props for the overlay the content renders behind itself.
Data attributeValue
[data-drawer-content]Present
[data-state]"open" | "closed"
[data-drawer-direction]"top" | "bottom" | "left" | "right"
[data-dragging]Present while a drag is in progress
[data-snap-points]Present when the root was given snap points
[data-snap-point]Index of the snap point the panel rests at
[data-nested]Present when this drawer is nested in another
[data-nested-open]Present when a nested drawer is open
[data-nested-count]Number of open nested drawers
[data-nested-level]Depth in the stack; 0 is the outermost drawer
CSS variableDescription
--ip-drawer-offset-xThe panel's horizontal translate, the drag and the active snap point together. Always 0px for a top or bottom drawer.
--ip-drawer-offset-yThe panel's vertical translate, the drag and the active snap point together. Always 0px for a left or right drawer.
--ip-drawer-progressHow far the drag has pulled the panel from its resting position, 0 to 1. Also written to the document while scaleBackground is on.
--ip-drawer-keyboard-insetHow much of the bottom of the viewport an on-screen keyboard has taken, in px, and 0px when it has taken none. A fixed panel sits against the layout viewport, which the keyboard does not shrink, so the keyboard covers the bottom of the panel. Spend this on space at the end of the panel rather than on the panel's own position: move the panel and the browser scrolls the page to chase the focused field.

DrawerHandle

The grab bar. It is the only drag surface when the root sets handleOnly, and tapping it steps to the next snap point (closing from the last one). Renders a span with data-drawer-handle-hitarea inside, for a hit area larger than the bar. Styled as a rounded bar at the dragging edge, laid out across the panel for a top or bottom drawer and down it for a left or right one. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
preventCyclebooleanfalseWhen true, tapping the handle no longer steps through the snap points.
Data attributeValue
[data-drawer-handle]Present
[data-state]"open" | "closed"

DrawerTitle

The heading. Put it inside the content. Wires up aria-labelledby on the panel. Renders a H2; extra props are forwarded onto it.

Data attributeValue
[data-drawer-title]Present

DrawerDescription

Supporting text. Put it inside the content. Wires up aria-describedby on the panel. Renders a P; extra props are forwarded onto it.

Data attributeValue
[data-drawer-description]Present

DrawerPortal

Renders its children into another DOM parent so the scrim and panel escape overflow and stacking. This is the core Portal helper; context still resolves from where the portal is declared.

PropTypeDefaultDescription
toHTMLElement | Readable<HTMLElement>document.bodyThe element to mount into. Also available as chained .To(target).
disabledboolean | Readable<boolean>falseMount in place instead of teleporting. Keep nested drawers portaled so they stack above the parent. Also available as chained .Disabled(value).

DrawerClose

Closes the drawer when clicked. Put it inside the content. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""outline"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"The button's height and padding scale. Also set as data-size.