ComponentsDrawer
Drawer
Open a panel from the edge of the screen.
On this page
Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.
View registry filesImport the component into your page or component file.
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHandle,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@dethink/components";Try the examples, then open the code to use them in your app.
Basic
Trigger, sized content, header anatomy, and footer with DrawerClose buttons.
Show sourceexamples/drawer/basic.tsx
"use client";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@dethink/components";
import { ShoppingCart } from "lucide-react";
const items = [
{
name: "Dashboard seat",
detail: "Pro workspace access",
quantity: "2",
price: "$48",
},
{
name: "Report export credit",
detail: "Monthly compliance bundle",
quantity: "1",
price: "$19",
},
];
export function DrawerBasic() {
return (
<div className="flex justify-center">
<Drawer direction="right">
<DrawerTrigger variant="outline">
<ShoppingCart aria-hidden="true" className="size-4" />
Review cart
</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close cart"
>
<DrawerHeader>
<DrawerTitle>Your cart</DrawerTitle>
<DrawerDescription>
Review the workspace changes before checkout.
</DrawerDescription>
</DrawerHeader>
<div className="text-foreground grid gap-[var(--dt-space-4)] px-[var(--dt-space-6)] py-[var(--dt-space-4)] text-sm">
<div className="grid gap-[var(--dt-space-3)]">
{items.map((item) => (
<div
className="border-border/70 bg-muted/30 grid gap-[var(--dt-space-2)] rounded-md border p-[var(--dt-space-3)]"
key={item.name}
>
<div className="flex items-start justify-between gap-[var(--dt-space-3)]">
<div>
<p className="font-medium">{item.name}</p>
<p className="text-muted-foreground">{item.detail}</p>
</div>
<p className="font-semibold">{item.price}</p>
</div>
<p className="text-muted-foreground text-xs tracking-[0.12em] uppercase">
Qty {item.quantity}
</p>
</div>
))}
</div>
<dl className="border-border/70 grid gap-[var(--dt-space-2)] border-t pt-[var(--dt-space-4)]">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="font-medium">$67</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Tax estimate</dt>
<dd className="font-medium">$5</dd>
</div>
<div className="flex justify-between text-base">
<dt className="font-medium">Total</dt>
<dd className="font-semibold">$72</dd>
</div>
</dl>
</div>
<DrawerFooter>
<DrawerClose variant="outline">Cancel</DrawerClose>
<DrawerClose>Checkout</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}Direction-aware sizing
One recipe exercises all four edges plus preset, full-axis, and custom dimension sizing.
Invoice review drawer
Pick an edge and size mode, then open the same drawer content.
Edge
Size
Uses size="lg" on the active edge.
Show sourceexamples/drawer/directional-sizing.tsx
"use client";
import { useState } from "react";
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
type DrawerDirection,
} from "@dethink/components";
const directions = [
{ value: "right", label: "Right", axis: "width" },
{ value: "left", label: "Left", axis: "width" },
{ value: "bottom", label: "Bottom", axis: "height" },
{ value: "top", label: "Top", axis: "height" },
] satisfies Array<{ value: DrawerDirection; label: string; axis: string }>;
const sizeModes = [
{
value: "preset",
label: "Preset lg",
summary: 'Uses size="lg" on the active edge.',
},
{
value: "custom",
label: "Custom",
summary: "Uses 28rem for side drawers or 68dvh for top and bottom drawers.",
},
{
value: "full",
label: "Full",
summary: "Fills the viewport on the drawer axis.",
},
] as const;
type SizeMode = (typeof sizeModes)[number]["value"];
function isVertical(direction: DrawerDirection) {
return direction === "top" || direction === "bottom";
}
export function DrawerDirectionalSizing() {
const [direction, setDirection] = useState<DrawerDirection>("right");
const [sizeMode, setSizeMode] = useState<SizeMode>("preset");
const customDimension = isVertical(direction) ? "68dvh" : "28rem";
const activeDirection = directions.find((item) => item.value === direction)!;
const activeMode = sizeModes.find((item) => item.value === sizeMode)!;
return (
<div className="border-border bg-background mx-auto grid w-full max-w-2xl gap-[var(--dt-space-5)] rounded-lg border p-[var(--dt-space-4)]">
<div className="grid gap-[var(--dt-space-3)] sm:grid-cols-[1fr_auto] sm:items-start">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
Invoice review drawer
</p>
<p className="text-muted-foreground text-sm leading-6">
Pick an edge and size mode, then open the same drawer content.
</p>
</div>
<Drawer
dimension={sizeMode === "custom" ? customDimension : undefined}
direction={direction}
fullSize={sizeMode === "full"}
size="lg"
>
<DrawerTrigger>Preview drawer</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close invoice drawer"
>
<DrawerHeader>
<DrawerTitle>Invoice INV-2048</DrawerTitle>
<DrawerDescription>
{activeDirection.label} drawer using{" "}
{activeMode.label.toLowerCase()}.
</DrawerDescription>
</DrawerHeader>
<div className="grid gap-[var(--dt-space-4)] px-[var(--dt-space-6)] py-[var(--dt-space-4)] text-sm">
<dl className="grid gap-[var(--dt-space-2)]">
<div className="flex justify-between gap-[var(--dt-space-4)]">
<dt className="text-muted-foreground">Axis</dt>
<dd className="text-foreground font-medium">
{activeDirection.axis}
</dd>
</div>
<div className="flex justify-between gap-[var(--dt-space-4)]">
<dt className="text-muted-foreground">Mode</dt>
<dd className="text-foreground font-medium">
{activeMode.label}
</dd>
</div>
<div className="flex justify-between gap-[var(--dt-space-4)]">
<dt className="text-muted-foreground">Custom dimension</dt>
<dd className="text-foreground font-medium">
{customDimension}
</dd>
</div>
</dl>
<div className="border-border/70 bg-muted/30 rounded-md border p-[var(--dt-space-3)]">
<p className="text-foreground font-medium">Acme procurement</p>
<p className="text-muted-foreground mt-1">
Subscription renewal, usage overage, and support add-on.
</p>
</div>
</div>
<DrawerFooter>
<DrawerClose variant="outline">Close</DrawerClose>
<DrawerClose>Approve invoice</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
<div className="grid gap-[var(--dt-space-3)] sm:grid-cols-2">
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-semibold tracking-[0.14em] uppercase">
Edge
</p>
<div
aria-label="Drawer edge"
className="border-border bg-muted/30 flex flex-wrap gap-[var(--dt-space-1)] rounded-md border p-[var(--dt-space-1)]"
role="group"
>
{directions.map((item) => (
<Button
aria-pressed={direction === item.value}
key={item.value}
onClick={() => setDirection(item.value)}
size="sm"
type="button"
variant={direction === item.value ? "solid" : "ghost"}
>
{item.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-semibold tracking-[0.14em] uppercase">
Size
</p>
<div
aria-label="Drawer size mode"
className="border-border bg-muted/30 flex flex-wrap gap-[var(--dt-space-1)] rounded-md border p-[var(--dt-space-1)]"
role="group"
>
{sizeModes.map((item) => (
<Button
aria-pressed={sizeMode === item.value}
key={item.value}
onClick={() => setSizeMode(item.value)}
size="sm"
type="button"
variant={sizeMode === item.value ? "solid" : "ghost"}
>
{item.label}
</Button>
))}
</div>
</div>
</div>
<p className="text-muted-foreground text-sm leading-6">
{activeMode.summary}
</p>
</div>
);
}Examples that combine components for common tasks.
Mobile navigation drawer
A left-anchored menu opened via the trigger or an edge swipe from the left.
Mobile filter bottom sheet
Snap points (35%, 65%, fully open) with a draggable handle and controlled active snap point.
Show sourceexamples/drawer/filter-bottom-sheet.tsx
"use client";
import { useState } from "react";
import {
Checkbox,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHandle,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Field,
FieldControl,
FieldLabel,
} from "@dethink/components";
const statuses = ["Open", "In review", "Blocked", "Done"];
export function DrawerFilterBottomSheet() {
const [activeSnapPoint, setActiveSnapPoint] = useState(0.5);
return (
<div className="flex justify-center">
<Drawer
activeSnapPoint={activeSnapPoint}
direction="bottom"
onActiveSnapPointChange={setActiveSnapPoint}
snapPoints={[0.35, 0.65, 1]}
>
<DrawerTrigger>Filter issues</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close filters"
>
<DrawerHandle aria-label="Drag to resize filters" />
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>
Drag the handle between 35%, 65%, and fully open, or flick down
fast to dismiss.
</DrawerDescription>
</DrawerHeader>
<div className="grid gap-[var(--dt-space-3)] px-[var(--dt-space-6)] py-[var(--dt-space-2)]">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
Status
</p>
{statuses.map((status) => (
<Field
id={`status-${status}`}
key={status}
orientation="horizontal"
>
<FieldControl asChild>
<Checkbox
defaultChecked={status === "Open"}
name="status"
value={status}
/>
</FieldControl>
<FieldLabel>{status}</FieldLabel>
</Field>
))}
</div>
<DrawerFooter>
<span className="text-muted-foreground text-sm">
Snap point: {activeSnapPoint}
</span>
<DrawerClose>Apply filters</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}Compact Android bottom sheet
A centered 450px-wide bottom sheet with icon-led quick links and light-dismiss.
Show sourceexamples/drawer/android-bottom-sheet.tsx
"use client";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHandle,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@dethink/components";
import {
Bell,
CalendarDays,
Download,
LifeBuoy,
Settings2,
Share2,
} from "lucide-react";
const quickLinks = [
{
description: "Send a secure workspace link",
href: "#",
icon: Share2,
label: "Share report",
},
{
description: "Review meetings and reminders",
href: "#",
icon: CalendarDays,
label: "Open schedule",
},
{
description: "Pull a CSV for this view",
href: "#",
icon: Download,
label: "Export data",
},
{
description: "Tune rules and thresholds",
href: "#",
icon: Bell,
label: "Notification rules",
},
];
const utilityLinks = [
{ href: "#", icon: Settings2, label: "Settings" },
{ href: "#", icon: LifeBuoy, label: "Support" },
];
export function DrawerAndroidBottomSheet() {
return (
<div className="flex justify-center">
<Drawer direction="bottom" motionPreset="expressive">
<DrawerTrigger variant="outline">Open compact sheet</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close quick actions"
dimension={450}
className="inset-x-[max(var(--dt-space-4),calc((100vw-450px)/2))] overflow-hidden"
>
<DrawerHandle aria-label="Drag to dismiss quick actions" />
<DrawerHeader>
<DrawerTitle>Quick actions</DrawerTitle>
<DrawerDescription>
A compact 450px Android-style sheet for high-frequency links.
</DrawerDescription>
</DrawerHeader>
<nav
aria-label="Quick actions"
className="grid gap-[var(--dt-space-3)] px-[var(--dt-space-3)] py-[var(--dt-space-3)]"
>
<ul className="grid gap-[var(--dt-space-1)]">
{quickLinks.map(({ description, href, icon: Icon, label }) => (
<li key={label}>
<a
className="text-foreground hover:bg-muted focus-visible:ring-ring focus-visible:ring-offset-background flex items-center gap-[var(--dt-space-3)] rounded-md px-[var(--dt-space-3)] py-[var(--dt-space-2)] text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
href={href}
>
<span className="border-border/70 bg-muted/60 text-foreground flex size-10 shrink-0 items-center justify-center rounded-md border">
<Icon aria-hidden="true" className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">{label}</span>
<span className="text-muted-foreground block text-xs">
{description}
</span>
</span>
</a>
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-[var(--dt-space-2)]">
{utilityLinks.map(({ href, icon: Icon, label }) => (
<a
className="border-border bg-background text-foreground hover:bg-muted focus-visible:ring-ring focus-visible:ring-offset-background flex items-center justify-center gap-[var(--dt-space-2)] rounded-md border px-[var(--dt-space-3)] py-[var(--dt-space-2)] text-sm font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
href={href}
key={label}
>
<Icon
aria-hidden="true"
className="text-muted-foreground size-4"
/>
{label}
</a>
))}
</div>
</nav>
<div className="border-border/60 border-t p-[var(--dt-space-3)]">
<DrawerClose className="w-full" variant="ghost">
Cancel
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
</div>
);
}Persistent inspector-rail push panel
modal={false} shifts the record list's layout instead of overlaying it, and never traps focus.
Northwind renewal
Push-mode drawers shift layout instead of overlaying it, so the record list stays reachable via Tab.
- ID
- REC-104
- Owner
- Priya Shah
Show sourceexamples/drawer/inspector-rail.tsx
"use client";
import { useState } from "react";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@dethink/components";
const records = [
{ id: "REC-104", name: "Northwind renewal", owner: "Priya Shah" },
{ id: "REC-118", name: "Acme onboarding", owner: "Marcus Lee" },
{ id: "REC-129", name: "Globex expansion", owner: "Dana Ruiz" },
];
export function DrawerInspectorRail() {
const [selected, setSelected] = useState(records[0]!);
return (
<div className="border-border flex min-h-[22rem] overflow-hidden rounded-lg border">
<main className="divide-border flex-1 divide-y overflow-y-auto">
{records.map((record) => (
<button
className="hover:bg-muted data-[current=true]:bg-muted flex w-full items-center justify-between px-[var(--dt-space-4)] py-[var(--dt-space-3)] text-left text-sm"
data-current={record.id === selected.id}
key={record.id}
onClick={() => setSelected(record)}
type="button"
>
<span className="text-foreground font-medium">{record.name}</span>
<span className="text-muted-foreground">{record.owner}</span>
</button>
))}
</main>
<Drawer defaultOpen direction="right" modal={false}>
<DrawerTrigger
className="border-border self-start rounded-none border-b"
variant="ghost"
>
Toggle inspector
</DrawerTrigger>
<DrawerContent size="sm">
<DrawerHeader>
<DrawerTitle>{selected.name}</DrawerTitle>
<DrawerDescription>
Push-mode drawers shift layout instead of overlaying it, so the
record list stays reachable via Tab.
</DrawerDescription>
</DrawerHeader>
<dl className="grid gap-[var(--dt-space-2)] px-[var(--dt-space-6)] py-[var(--dt-space-2)] text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">ID</dt>
<dd className="text-foreground font-medium">{selected.id}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Owner</dt>
<dd className="text-foreground font-medium">{selected.owner}</dd>
</div>
</dl>
</DrawerContent>
</Drawer>
</div>
);
}Scheduler event-detail drawer
backgroundScale dims and scales the page behind a modal drawer opened from a calendar grid.
Show sourceexamples/drawer/scheduler-event-detail.tsx
"use client";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHandle,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
drawerBackgroundWrapperClassNames,
} from "@dethink/components";
import { Clock, MapPin, Users } from "lucide-react";
export function DrawerSchedulerEventDetail() {
return (
<div
className={drawerBackgroundWrapperClassNames({
className:
"border-border bg-background flex justify-center rounded-lg border p-6",
})}
data-drawer-background-wrapper=""
>
<Drawer backgroundScale direction="right">
<DrawerTrigger>Open event</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close event"
>
<DrawerHandle aria-label="Drag to dismiss" />
<DrawerHeader>
<DrawerTitle>Quarterly planning review</DrawerTitle>
<DrawerDescription>
Selected from the scheduler grid. Drag the handle down, or use
Escape/outside click, to dismiss.
</DrawerDescription>
</DrawerHeader>
<div className="text-foreground grid gap-[var(--dt-space-3)] px-[var(--dt-space-6)] py-[var(--dt-space-2)] text-sm">
<div className="flex items-center gap-[var(--dt-space-2)]">
<Clock
aria-hidden="true"
className="text-muted-foreground size-4"
/>
Tue, Mar 10 ยท 10:00โ11:00 AM
</div>
<div className="flex items-center gap-[var(--dt-space-2)]">
<MapPin
aria-hidden="true"
className="text-muted-foreground size-4"
/>
Conference room B / video link
</div>
<div className="flex items-center gap-[var(--dt-space-2)]">
<Users
aria-hidden="true"
className="text-muted-foreground size-4"
/>
6 attendees
</div>
</div>
<DrawerFooter>
<DrawerClose variant="outline">Close</DrawerClose>
<DrawerClose>Join call</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}Nested drill-down edit flow
A drawer opened from inside another drawer automatically recedes its parent.
Show sourceexamples/drawer/nested-drill-down-edit.tsx
"use client";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@dethink/components";
export function DrawerNestedDrillDownEdit() {
return (
<div className="flex justify-center">
<Drawer direction="right">
<DrawerTrigger>Open record</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close record"
>
<DrawerHeader>
<DrawerTitle>Record</DrawerTitle>
<DrawerDescription>
Opening the nested edit drawer recedes this one โ the same spring
primitives as drag-to-dismiss, not a separate animation path.
</DrawerDescription>
</DrawerHeader>
<div className="text-foreground px-[var(--dt-space-6)] py-[var(--dt-space-3)] text-sm">
Status: <span className="font-medium">In review</span>
</div>
<Drawer direction="right">
<DrawerTrigger className="ms-[var(--dt-space-6)]" variant="outline">
Edit status
</DrawerTrigger>
<DrawerContent
dismissible
showCloseButton
closeButtonLabel="Close status editor"
>
<DrawerHeader>
<DrawerTitle>Edit status</DrawerTitle>
<DrawerDescription>
A drawer opened from inside another drawer.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose>Done</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</DrawerContent>
</Drawer>
</div>
);
}Drawer coordinates the trigger and open state; DrawerContent carries the panel, motion, and dismissal options.
| Prop | What it does | Default |
|---|---|---|
direction"top" | "bottom" | "left" | "right" | Anchor edge. A physical anchor โ it never repositions under RTL, only internal spacing does. | "bottom" |
modalboolean | false renders a non-modal inline push panel that shifts sibling layout instead of overlaying it. | true |
size / fullSize / dimension"sm" | "md" | "lg" | "xl" | "full" / boolean / string | number | Direction-aware drawer sizing. For top/bottom drawers this controls height; for left/right drawers this controls width. dimension accepts custom CSS lengths, with numbers treated as px. | "md" / false / โ |
open / defaultOpen / onOpenChangeboolean / boolean / (open) => void | Controlled or uncontrolled open state, identical to Dialog's contract. | Not set |
DrawerContent โ dismissible / keyboardDismissDisabledboolean / boolean | Outside-click dismissal when dismissible is true, plus Escape dismissal in modal mode. Requires a visible close affordance when keyboard dismiss is disabled. | false / false |
snapPoints / activeSnapPoint / defaultSnapPoint / onActiveSnapPointChangenumber[] / number / number / (snapPoint) => void | Fractions in (0, 1] of the drawer's open size it can rest at. 0 (closed) is always implicit. | Not set |
closeThreshold / velocityThresholdnumber / number | Distance fraction and px/s flick velocity that trigger a drag dismiss below the distance threshold. | 0.25 / 500 |
dragHandleOnlyboolean | Restricts drag initiation to DrawerHandle/header region. | true |
backgroundScaleboolean | iOS-style scale-down/dim on a data-drawer-background-wrapper element while a modal drawer is open. | false |
edgeSwipeToOpen / edgeSwipeHitRegionSizeboolean / number | Opt-in edge-swipe gesture to open the drawer, and its hit-region size in px. | false / 24 |
motionPreset"none" | "subtle" | "standard" | "expressive" | Spring/duration tuning applied consistently to drag, snap, recede, and shared-element morph. | "standard" |
reducedMotionboolean | Explicit override that forces the CSS-only fallback regardless of prefers-reduced-motion. | Not set |
DrawerContent โ layoutIdstring | Passthrough to the underlying Motion element for an optional shared-element entrance from a matching motion.* trigger. | Not set |
DrawerHandledraggable affordance | aria-hidden, additive pointer-drag handle. Trigger, close, dismissible outside click, and Escape work without it. | Not set |
Nested drawersautomatic | A drawer opened from inside another drawer automatically recedes its parent using the same spring primitives as drag. Recommended max stack depth: 2. | Not set |
DrawerHeader / DrawerTitle / DrawerDescription / DrawerFooter / DrawerClosesection components | Anatomy pieces; the title and description label the drawer for assistive tech. | Not set |