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.
Copy the file below to src/lib/components/ui/drawer.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Div, Span, type Child, type ComponentProps, type Mountable } from "@implementjs/core";
import { XIcon } from "@implementjs/lucide";
import {
createComponent,
Drawer as DrawerPrimitive,
DrawerClose as DrawerClosePrimitive,
DrawerContent as DrawerContentPrimitive,
DrawerCtx,
DrawerDescription as DrawerDescriptionPrimitive,
DrawerHandle as DrawerHandlePrimitive,
DrawerOverlay as DrawerOverlayPrimitive,
DrawerPortal as DrawerPortalPrimitive,
DrawerTitle as DrawerTitlePrimitive,
DrawerTrigger as DrawerTriggerPrimitive,
type DrawerDirection,
} from "@implementjs/primitives";
import { buttonVariants, type ButtonSize, type ButtonVariant } from "./button";
import { cn } from "@/lib/utils";
/**
* A drawer is a panel you can throw back out. It is the dialog's focus trap,
* escape and outside dismissal, and aria wiring, 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) according to how fast it was let go.
*
* Every part reads `direction` off the root, so the panel, its handle, and the
* scrim only have to be told which edge to live on once.
*/
/** The motion Vaul uses, and what makes a thrown panel feel like it has weight. */
const EASE = "duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none";
export type DrawerProps = ComponentProps<typeof DrawerPrimitive>;
export type DrawerTriggerProps = ComponentProps<typeof DrawerTriggerPrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type DrawerOverlayProps = ComponentProps<typeof DrawerOverlayPrimitive>;
export type DrawerContentProps = ComponentProps<typeof DrawerContentPrimitive> & {
/** Show the grab bar at the dragging edge. Defaults to true. */
showHandle?: boolean;
/** Show a close button in the corner. Defaults to false — the handle is the affordance. */
showCloseButton?: boolean;
/** Props for the overlay the content renders behind itself. */
overlay?: DrawerOverlayProps;
};
export type DrawerHandleProps = ComponentProps<typeof DrawerHandlePrimitive>;
export type DrawerCloseProps = ComponentProps<typeof DrawerClosePrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type DrawerTitleProps = ComponentProps<typeof DrawerTitlePrimitive>;
export type DrawerDescriptionProps = ComponentProps<typeof DrawerDescriptionPrimitive>;
export const DrawerPortal = DrawerPortalPrimitive;
export const Drawer = createComponent(function Drawer(props: DrawerProps, ...children: Child[]) {
return DrawerPrimitive(props, ...children);
});
export const DrawerTrigger = createComponent(function DrawerTrigger(
{
class: className,
variant = "outline",
size = "default",
type = "button",
...props
}: DrawerTriggerProps,
...children: Child[]
) {
return DrawerTriggerPrimitive(
{
type,
...props,
"data-slot": "drawer-trigger",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});
/**
* The scrim. Its opacity is `--ip-drawer-fade`, which the drag drives directly,
* so the page behind comes back as the panel is pulled away — and stays clear
* while the panel rests below the snap point the overlay fades in from.
*/
export const DrawerOverlay = createComponent(function DrawerOverlay(
{ class: className, ...props }: DrawerOverlayProps,
...children: Child[]
) {
return DrawerOverlayPrimitive(
{
...props,
"data-slot": "drawer-overlay",
class: cn(
"fixed inset-0 z-[calc(50+var(--ip-nested-level,0))] bg-black/50",
"[opacity:var(--ip-drawer-fade,1)]",
`transition-[opacity,display] ${EASE} transition-discrete`,
"data-[state=open]:block",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:opacity-0",
"starting:data-[state=open]:opacity-0",
// the panel is following a finger; the scrim has to keep up frame for frame
"data-[dragging]:transition-none",
"data-[nested]:bg-transparent",
className,
),
},
...children,
);
});
/**
* Per edge: where the panel sits, which corners it rounds, where it goes when
* it closes, and the `::after` overscroll patch that keeps the background from
* peeling away from the edge when a drag pulls the panel past its open stop.
*/
const directionClasses: Record<DrawerDirection, string> = {
bottom: [
"inset-x-0 bottom-0 mt-24 max-h-[min(calc(85dvh+var(--ip-drawer-keyboard-inset,0px)),100dvh)] rounded-t-lg border-t",
"data-[snap-points]:mt-0 data-[snap-points]:h-full data-[snap-points]:max-h-none",
"data-[state=closed]:[translate:0_100%] starting:data-[state=open]:[translate:0_100%]",
"after:inset-x-0 after:top-full after:h-[200%]",
].join(" "),
top: [
"inset-x-0 top-0 mb-24 max-h-[min(85dvh,calc(100dvh-var(--ip-drawer-keyboard-inset,0px)))] rounded-b-lg border-b",
"data-[snap-points]:mb-0 data-[snap-points]:h-full data-[snap-points]:max-h-none",
"data-[state=closed]:[translate:0_-100%] starting:data-[state=open]:[translate:0_-100%]",
"after:inset-x-0 after:bottom-full after:h-[200%]",
].join(" "),
left: [
"inset-y-0 left-0 w-3/4 max-w-sm rounded-r-lg border-r",
"data-[snap-points]:w-full data-[snap-points]:max-w-none",
"data-[state=closed]:[translate:-100%_0] starting:data-[state=open]:[translate:-100%_0]",
"after:inset-y-0 after:right-full after:w-[200%]",
].join(" "),
right: [
"inset-y-0 right-0 w-3/4 max-w-sm rounded-l-lg border-l",
"data-[snap-points]:w-full data-[snap-points]:max-w-none",
"data-[state=closed]:[translate:100%_0] starting:data-[state=open]:[translate:100%_0]",
"after:inset-y-0 after:left-full after:w-[200%]",
].join(" "),
};
/**
* The panel does not move for the keyboard — it stays anchored, and the
* keyboard covers the bottom of it, which is what keeps the top of the panel
* and everything near it exactly where the reader last saw it. This holds the
* content itself clear: an empty box at the end of the panel's column, as tall
* as the keyboard, so the flex layout above it lands in the space that is left.
*
* It pairs with the height cap in `directionClasses`, which grows by the same
* inset. Without that the spacer would be squeezed out of a capped panel and
* take the last of the content down behind the keyboard with it.
*
* A spacer rather than `padding-bottom` because padding is the first thing a
* caller reaches for `class` to change, and this is not theirs to lose.
*
* Only for the edges the keyboard rises into. A top drawer hangs from the top
* of the screen, so a box at the end of its column would grow it further under
* the keyboard rather than clear of it — that one caps its height instead.
*/
function KeyboardSpacer(): Mountable {
return Div({
"data-slot": "drawer-keyboard-spacer",
"aria-hidden": true,
class: "h-[var(--ip-drawer-keyboard-inset,0px)] shrink-0",
});
}
/**
* Where the grab bar goes, which is always the edge the panel drags out of —
* not the edge it is anchored to. A bar across the panel for a top or bottom
* drawer, and one down the side for a left or right one, taken out of flow
* because a column layout should not be built around 6px of grab bar.
*/
const handleClasses: Record<DrawerDirection, string> = {
bottom: "mx-auto my-4 h-1.5 w-12",
top: "mx-auto my-4 h-1.5 w-12",
left: "absolute top-1/2 right-2.5 h-12 w-1.5 -translate-y-1/2",
right: "absolute top-1/2 left-2.5 h-12 w-1.5 -translate-y-1/2",
};
/**
* The panel, with the scrim and the portal already inside it. `DrawerOverlay`
* and `DrawerPortal` stay exported for a layout that composes the panel out of
* the primitives — pairing them with this only gets you two scrims.
*/
export const DrawerContent = createComponent(function DrawerContent(
{
class: className,
showHandle = true,
showCloseButton = false,
overlay = {},
...props
}: DrawerContentProps,
...children: Child[]
) {
return DrawerCtx.Use((state) =>
DrawerPortal(
DrawerOverlay(overlay),
DrawerContentPrimitive(
{
...props,
"data-slot": "drawer-content",
class: cn(
"fixed z-[calc(50+var(--ip-nested-level,0))] flex flex-col overscroll-contain border-border bg-background text-foreground shadow-lg outline-none pointer-fine:select-none",
// the panel's own resting place: the active snap point plus the drag
"[translate:var(--ip-drawer-offset-x,0px)_var(--ip-drawer-offset-y,0px)]",
`transition-[translate,display] ${EASE} transition-discrete`,
"data-[state=open]:flex data-[state=closed]:pointer-events-none data-[state=closed]:hidden",
"data-[dragging]:transition-none",
// covers the gap a rubber-banded overdrag would otherwise open at the edge
"after:pointer-events-none after:absolute after:bg-inherit after:content-['']",
directionClasses[state.direction],
className,
),
},
// a top drawer drags out of its bottom edge, so the bar belongs after
// the content rather than before it
showHandle && state.direction !== "top"
? DrawerHandle({ class: handleClasses[state.direction] })
: null,
...children,
showHandle && state.direction === "top" ? DrawerHandle({ class: handleClasses.top }) : null,
state.direction !== "top" ? KeyboardSpacer() : null,
showCloseButton
? DrawerClose(
{
variant: "ghost",
size: "icon-sm",
class: "absolute top-3 right-3",
},
XIcon({ class: "size-4", "aria-hidden": true }),
Span({ class: "sr-only" }, "Close"),
)
: null,
),
),
);
});
/**
* The grab bar. Dragging it moves the panel; tapping it steps to the next snap
* point and closes from the last one. It carries a 44px hit area that does not
* change the bar's own size.
*/
export const DrawerHandle = createComponent(function DrawerHandle(
{ class: className, ...props }: DrawerHandleProps,
...children: Child[]
) {
return DrawerHandlePrimitive(
{
...props,
"data-slot": "drawer-handle",
class: cn(
"relative shrink-0 cursor-grab touch-none rounded-full bg-muted opacity-70 transition-opacity hover:opacity-100 active:cursor-grabbing active:opacity-100",
// the bar a bottom drawer wants; DrawerContent overrides it per direction
"mx-auto my-4 h-1.5 w-12",
"[&>[data-drawer-handle-hitarea]]:absolute [&>[data-drawer-handle-hitarea]]:top-1/2 [&>[data-drawer-handle-hitarea]]:left-1/2 [&>[data-drawer-handle-hitarea]]:h-[max(100%,2.75rem)] [&>[data-drawer-handle-hitarea]]:w-[max(100%,2.75rem)] [&>[data-drawer-handle-hitarea]]:-translate-x-1/2 [&>[data-drawer-handle-hitarea]]:-translate-y-1/2",
className,
),
},
...children,
);
});
export const DrawerTitle = createComponent(function DrawerTitle(
{ class: className, ...props }: DrawerTitleProps,
...children: Child[]
) {
return DrawerTitlePrimitive(
{
...props,
"data-slot": "drawer-title",
class: cn("text-lg leading-none font-semibold", className),
},
...children,
);
});
export const DrawerDescription = createComponent(function DrawerDescription(
{ class: className, ...props }: DrawerDescriptionProps,
...children: Child[]
) {
return DrawerDescriptionPrimitive(
{
...props,
"data-slot": "drawer-description",
class: cn("text-sm text-muted-foreground", className),
},
...children,
);
});
export const DrawerClose = createComponent(function DrawerClose(
{
class: className,
variant = "outline",
size = "default",
type = "button",
...props
}: DrawerCloseProps,
...children: Child[]
) {
return DrawerClosePrimitive(
{
type,
...props,
"data-slot": "drawer-close",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});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.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The 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. |
dismissible | boolean | true | When 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. |
activeSnapPoint | Signal<number | string | null> | number | string | null | snapPoints[0] | The snap point the panel rests at. Pass a signal to read the current one or move the panel from outside. |
fadeFromIndex | number | snapPoints.length - 1 | The snap point the overlay finishes fading in at. Below it the overlay is clear, so the page behind stays usable to look at. |
snapToSequentialPoint | boolean | false | When true a hard fling moves one snap point instead of skipping to the far end. For drawers where every snap point matters. |
closeThreshold | number | 0.25 | Fraction of the panel a slow drag has to cover before releasing dismisses it. A fast one dismisses on velocity alone. |
scrollLockTimeout | number | 100 | ms after scrolling inside the panel during which a drag will not start, so the end of a scroll does not throw the drawer. |
handleOnly | boolean | false | When true only DrawerHandle starts a drag; the rest of the panel does not. |
scaleBackground | boolean | false | Marks 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. |
preventScroll | boolean | true | When 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
default | boolean | false | When the drawer starts open, return focus to this trigger instead of the first one in the tree. |
| Data attribute | Value |
|---|---|
[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 attribute | Value |
|---|---|
[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 variable | Description |
|---|---|
--ip-drawer-fade | The scrim's opacity, 1 covering the page and 0 clear. Follows the drag, and stays 0 while the panel rests below fadeFromIndex. |
--ip-drawer-progress | How 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.
| Prop | Type | Default | Description |
|---|---|---|---|
showHandle | boolean | true | Renders a DrawerHandle as the panel's first child. |
showCloseButton | boolean | false | Renders a DrawerClose in the top right corner. Off by default — the handle, the scrim, and Escape are the affordances. |
overlay | DrawerOverlayProps | {} | Props for the overlay the content renders behind itself. |
| Data attribute | Value |
|---|---|
[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 variable | Description |
|---|---|
--ip-drawer-offset-x | The panel's horizontal translate, the drag and the active snap point together. Always 0px for a top or bottom drawer. |
--ip-drawer-offset-y | The panel's vertical translate, the drag and the active snap point together. Always 0px for a left or right drawer. |
--ip-drawer-progress | How 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-inset | How 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.
| Prop | Type | Default | Description |
|---|---|---|---|
preventCycle | boolean | false | When true, tapping the handle no longer steps through the snap points. |
| Data attribute | Value |
|---|---|
[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 attribute | Value |
|---|---|
[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 attribute | Value |
|---|---|
[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.
| Prop | Type | Default | Description |
|---|---|---|---|
to | HTMLElement | Readable<HTMLElement> | document.body | The element to mount into. Also available as chained .To(target). |
disabled | boolean | Readable<boolean> | false | Mount 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |