implement
Drawer

Drawer

A panel that slides in from an edge, 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"),
				),
			),
		),
	);
}

A drawer is a dialog you can throw back out. It is the same modal base — focus trap, Escape, outside dismissal, scroll lock, nesting, aria-modal — with a drag on top: the panel follows the pointer, rubber bands past its open position, and lands on a snap point or off the screen depending on how fast it was let go. This is a port of Vaul.

Drawer is the root, DrawerTrigger toggles it, DrawerContent is the panel, and DrawerHandle is the grab bar. Wrap the scrim and panel in DrawerPortal so they escape overflow.

import {
	Drawer,
	DrawerClose,
	DrawerContent,
	DrawerDescription,
	DrawerHandle,
	DrawerOverlay,
	DrawerPortal,
	DrawerTitle,
	DrawerTrigger,
} from "@implementjs/primitives";

Drawer(
	DrawerTrigger("Move goal"),
	DrawerPortal(
		DrawerOverlay(),
		DrawerContent(
			DrawerHandle(),
			DrawerTitle("Move goal"),
			DrawerDescription("Set your daily activity goal."),
			DrawerClose("Submit"),
		),
	),
);

Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Everything the drawer does not consume is forwarded onto the underlying Button, Div, H2, or P.

Direction

direction picks the edge the panel is anchored to, which is also the way it drags out. It defaults to "bottom".

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."),
	);
}

The direction reaches the parts as data-drawer-direction, so one stylesheet can cover all four.

Placing the panel

The primitive does not position or hide the panel — it only says where the drag has put it. DrawerContent writes --ip-drawer-offset-x and --ip-drawer-offset-y, a pair of px lengths that carry the drag and the active snap point together, with the cross-axis one always 0px. Spend them on translate and pin the panel to its edge yourself:

DrawerContent({
	class: [
		"fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-lg border-t bg-background",
		"[translate:var(--ip-drawer-offset-x)_var(--ip-drawer-offset-y)]",
		"transition-[translate,display] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] transition-discrete",
		"data-[state=closed]:hidden data-[state=closed]:[translate:0_100%]",
		"starting:data-[state=open]:[translate:0_100%]",
		// while a finger is on it the panel is not animating, it is tracking
		"data-[dragging]:transition-none",
	],
});

data-dragging is the one rule that is not optional. A transition left on during a drag turns a panel that tracks the pointer into one that lags behind it.

Dragging

A drag starts anywhere on the panel, unless handleOnly moves it to the handle. What happens on release depends on the gesture, not only where it ended:

  • Past closeThreshold — a quarter of the panel by default — dismisses it.
  • A flick faster than 0.4px/ms dismisses it however short it was.
  • Anything else springs back.

Pulling the panel further open than it goes rubber bands instead of stopping dead. dismissible: false takes dismissal away from everything the drawer owns — the drag, Escape, the scrim, and DrawerClose — so drive open yourself. A drawer nested inside one that closes still goes with it, rather than being left on the page with nothing above it.

Two things a drag deliberately does not do. It does not start from a scroll container that is scrolled away from the edge the panel would leave from, and it does not start for scrollLockTimeout after one has scrolled — so a panel with a list in it scrolls to the top first and drags second. It also does not start from anything inside [data-drawer-no-drag], which is the escape hatch for a slider, a map, or a canvas that wants the gesture for itself.

NOTE

Give scrollable regions inside the panel overscroll-behavior: contain. Without it a touch that runs past the end of the list scrolls the page behind the drawer instead of doing nothing.

On-screen keyboards

A fixed panel is placed against the layout viewport, and a phone keyboard does not shrink that — it shrinks the visual viewport on top of it. So a keyboard opens over the bottom of the panel, and if anything moves the panel to get out of its way, the browser scrolls the page to chase the field that was just focused, and what the reader was looking at goes with it.

So don't move it. Leave the panel where it is, let the keyboard cover the bottom of it, and hold the content clear — the way an iOS sheet does. Whatever sits near the top of the panel then stays exactly where the reader last saw it, keyboard or no keyboard, which is the whole trick: a field up there never has to be scrolled into view because it never left.

DrawerContent writes --ip-drawer-keyboard-inset: how much of the bottom of the viewport the keyboard has taken, in px, and 0px when it has taken none. Spend it on space at the end of the panel rather than on the panel's own position:

DrawerContent(
	{
		class: [
			"fixed inset-x-0 bottom-0 flex flex-col",
			// what is being held to 85dvh is the part of the panel you can see, so the
			// cap has to grow by the keyboard's share too — up to the whole screen
			"max-h-[min(calc(85dvh+var(--ip-drawer-keyboard-inset,0px)),100dvh)]",
		],
	},
	SearchField(),
	Div({ class: "min-h-0 flex-1 overflow-y-auto" }, Results()),
	// the keyboard's share of the panel, so the column above it lands in what is left
	Div({ class: "h-[var(--ip-drawer-keyboard-inset,0px)] shrink-0", "aria-hidden": true }),
);

Both halves, or neither. A height cap that does not know about the inset squeezes the spacer's height back out of the content, and the end of the column — the submit button, usually — goes down behind the keyboard, which is the thing the spacer was there to prevent.

This is for the edges the keyboard rises into. A panel hanging from the top of the screen has no room at the end of its column to give away; cap it at min(85dvh,calc(100dvh-var(--ip-drawer-keyboard-inset,0px))) and leave the spacer out.

The measurement is live for as long as the drawer is open, and it comes from visualViewport — the same reading Vaul takes for repositionInputs. What it does with it is the part that differs: Vaul resizes and repositions the panel, and this leaves the panel alone.

It is not symmetric. A keyboard that has grown is published at once, because until the panel makes room the keyboard is sitting on top of it. A keyboard that has shrunk has to stay shrunk for a moment first. Moving focus from one field to the next starts the keyboard dismissing and then brings it straight back, and a panel that lays itself out again for those few frames moves every field in it down and back — which reads as the keyboard flickering, not as the panel doing anything. Holding the larger number through the handoff costs nothing, because the keyboard never actually left.

Nothing reads it for you, so a drawer that never holds a field can ignore it. And because nothing moves, a wrong reading costs you a covered footer rather than a panel scrolled off the top.

Snap points

snapPoints gives the panel resting positions, ordered least to most of the screen. A number is a fraction of the viewport and a string is a length the viewport does not enter into:

Drawer({ snapPoints: [0.4, 0.75, 1] }, DrawerTrigger("Changelog"), DrawerContent("…"));

Drawer({ snapPoints: ["148px", 1] }, DrawerTrigger("Now playing"), DrawerContent("…"));
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")),
							),
					),
				),
			),
		),
	);
}

With snap points the panel should fill its axis — the offset is what reveals part of it — so style it h-full under data-snap-points rather than capping its height.

A release picks the nearest snap point. A flick moves one along, and a hard one (above 2px/ms) skips to the end, or dismisses if it was already at the smallest. snapToSequentialPoint: true turns off the skip, for a drawer where each stop matters.

Read or drive the current one with activeSnapPoint:

const snap = signal<number | string | null>(0.4);

Drawer({ snapPoints: [0.4, 0.75, 1], activeSnapPoint: snap }, …);

Button({ onClick: () => snap.set(1) }, "Expand");

The panel writes the index it is resting at as data-snap-point, so a header can lay itself out differently once the drawer is full height.

Fading the scrim

DrawerOverlay writes --ip-drawer-fade, the opacity the scrim should have right now: 1 when it covers the page and 0 when it is clear. Without snap points it tracks the drag, so the page comes back as the panel is pulled away. With them it is clear below fadeFromIndex — the last snap point unless you say otherwise — and crosses to solid over the step into it, which is what lets a drawer sit at a third of the screen without dimming the page behind it.

DrawerOverlay({
	class: [
		"fixed inset-0 z-50 bg-black/50 [opacity:var(--ip-drawer-fade,1)]",
		"transition-[opacity,display] duration-500 transition-discrete",
		"data-[state=closed]:hidden data-[state=closed]:opacity-0",
		"data-[dragging]:transition-none",
	],
});

The overlay also carries data-faded-in while the panel rests at or above fadeFromIndex, for styling that has to be a step rather than a fraction.

Handle

DrawerHandle is the grab bar. It renders a Span with data-drawer-handle-hitarea inside it, which is what a 44px touch target can be hung on without making the bar itself that big.

Tapping it steps to the next snap point and closes from the last one; preventCycle turns that off. A press held long enough to have been a drag does not count as a tap. With no snap points there is nothing to step through, so a tap does nothing.

handleOnly on the root makes it the only place a drag can start, for a panel whose whole body is interactive.

Drawer(
	{ handleOnly: true, snapPoints: [0.4, 1] },
	DrawerTrigger("Layers"),
	DrawerContent(DrawerHandle(), MapCanvas()),
);

Scaling the page behind

scaleBackground marks the document while an outermost drawer is open — data-drawer-open, plus --ip-drawer-scale and --ip-drawer-progress — and leaves the transform to CSS. Mark the element that should shrink with data-drawer-wrapper:

[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);
}

--ip-drawer-scale already folds the drag in: the page comes back to full size as the panel is pulled away, and the whole thing reverses if the drag springs back. Nested drawers leave the document alone — the thing behind them is another panel, not the page.

Nested

Each Drawer provides its own context, so a second root inside the panel talks to its own trigger, panel, and handle. Nested drawers get the same stacking variables the dialog does: data-nested, data-nested-open, --ip-nested-count for scaling the panel underneath, and --ip-nested-level for raising the one on top. Closing a parent closes the drawers nested in it.

Title and description

DrawerTitle is an H2 and DrawerDescription is a P. Put them inside the content; the panel's aria-labelledby and aria-describedby point at them. If you skip the title, set aria-label on the content yourself.

The handle is aria-hidden, so it is not a control anyone can reach — Escape, the scrim, and DrawerClose are what close the drawer without a pointer.

What is not ported

Vaul's non-modal drawer (modal={false}) and its scroll restoration are not here. preventScroll: false covers the part of the first one that is about the page behind still scrolling. Vaul's keyboard repositioning is here in measurement form — see On-screen keyboards — but it does not resize the panel for you the way repositionInputs and fixed do.

API Reference

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
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. 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. Renders a Div; extra props are forwarded onto it.

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