Drawer
A sheet that slides in from the right, left or bottom for details read alongside the page: a run, a trace, a record, a settings panel.
Install#
npx shadcn@latest add @pixilui/drawerOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/drawer.json
- 1
Install the npm packages.
npm install @base-ui/react motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"use client"; import { forwardRef, isValidElement, useCallback, useId, useMemo, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactElement, type ReactNode, } from "react"; import { Dialog as BaseDialog } from "@base-ui/react/dialog"; import { AnimatePresence, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react"; import { cn } from "./lib/utils"; import { motionTokens, reducedTransition } from "./motion-tokens"; import { DialogActions, DialogClose, DialogDescription, DialogProvider, DialogTitle, dialogTriggerIsNativeButton, type DialogContainer, } from "./dialog"; export type DrawerSide = "right" | "left" | "bottom"; export type DrawerSize = "sm" | "md" | "lg"; export type DrawerProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> & { /** The drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions. */ children?: ReactNode; /** Controlled open state. Pair with onOpenChange. */ open?: boolean; /** Starting open state when uncontrolled. */ defaultOpen?: boolean; /** Runs whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action. */ onOpenChange?: (open: boolean) => void; /** The element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works. */ trigger?: ReactElement; /** The edge the sheet slides from. */ side?: DrawerSide; /** Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen). */ size?: DrawerSize; /** Close on Esc, an outside press and, for bottom drawers on touch screens, a swipe down. */ dismissible?: boolean; /** Where the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */ container?: DialogContainer; /** true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. */ modal?: boolean | "trap-focus"; }; const panelSides: Record<DrawerSide, string> = { right: "inset-y-2 right-2 w-[calc(100%-1rem)]", left: "inset-y-2 left-2 w-[calc(100%-1rem)]", bottom: "inset-x-2 bottom-2 mx-auto max-w-2xl", }; const panelSizes: Record<DrawerSide, Record<DrawerSize, string>> = { right: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" }, left: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" }, bottom: { sm: "max-h-[40%]", md: "max-h-[65%]", lg: "max-h-[90%]" }, }; /** How far off screen the sheet waits: past its own size plus the 8px inset. */ const offscreen: Record<DrawerSide, { x?: string; y?: string }> = { right: { x: "110%" }, left: { x: "-110%" }, bottom: { y: "110%" }, }; /** A swipe closes the sheet past this share of its height, or faster than this many px/s. */ const SWIPE_DISTANCE = 0.3; const SWIPE_VELOCITY = 600; /** Upward pull resistance: the sheet gives about 10px before it stops. */ const PULL_ELASTIC = 0.04; const coarseQuery = "(pointer: coarse)"; const subscribeCoarse = (onChange: () => void) => { const list = window.matchMedia(coarseQuery); list.addEventListener("change", onChange); return () => list.removeEventListener("change", onChange); }; const readCoarse = () => window.matchMedia(coarseQuery).matches; const readCoarseOnServer = () => false; function usePointerCoarse() { return useSyncExternalStore(subscribeCoarse, readCoarse, readCoarseOnServer); } export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer( { children, open: openProp, defaultOpen = false, onOpenChange, trigger, side = "right", size = "md", dismissible = true, container, modal, className, ...rest }, forwardedRef, ) { const reduced = useReducedMotion() ?? false; const coarse = usePointerCoarse(); const dragControls = useDragControls(); const [innerOpen, setInnerOpen] = useState(defaultOpen); const open = openProp ?? innerOpen; const generatedTriggerId = useId(); const triggerId = (isValidElement<{ id?: string }>(trigger) && trigger.props.id) || generatedTriggerId; const cancelRef = useRef<HTMLButtonElement | null>(null); const pendingRef = useRef(false); const panelRef = useRef<HTMLDivElement | null>(null); const swipeable = side === "bottom" && coarse && dismissible; const setOpen = useCallback( (next: boolean) => { setInnerOpen(next); onOpenChange?.(next); }, [onOpenChange], ); const context = useMemo( () => ({ variant: "default" as const, close: () => setOpen(false), cancelRef, setPending: (pending: boolean) => { pendingRef.current = pending; }, }), [setOpen], ); const setPanelRef = useCallback( (node: HTMLDivElement | null) => { panelRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef], ); const onDragEnd = (event: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => { const height = (event.target as HTMLElement | null)?.closest('[data-slot="drawer"]')?.getBoundingClientRect().height ?? 0; if (info.offset.y > height * SWIPE_DISTANCE || info.velocity.y > SWIPE_VELOCITY) setOpen(false); }; const backdrop = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition } : { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: motionTokens.duration.medium, ease: motionTokens.ease.smoothOut } }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.smoothOut }, }; const panel = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition } : { initial: { opacity: 0, ...offscreen[side] }, animate: { opacity: 1, x: 0, y: 0 }, // The exit is a spring too, so a flick carries its velocity off screen. exit: { opacity: 0, ...offscreen[side], transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.medium, ease: motionTokens.ease.exit } }, }, transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter } }, }; const state = open ? "open" : "closed"; return ( <BaseDialog.Root open={open} triggerId={trigger ? triggerId : undefined} modal={modal ?? (container !== undefined ? "trap-focus" : true)} disablePointerDismissal={!dismissible} onOpenChange={(next, details) => { if (!next && (pendingRef.current || (!dismissible && details.reason === "escape-key"))) { details.cancel(); return; } setOpen(next); }} > {trigger ? <BaseDialog.Trigger id={triggerId} nativeButton={dialogTriggerIsNativeButton(trigger)} render={trigger} /> : null} <AnimatePresence> {open ? ( <BaseDialog.Portal keepMounted container={container}> <BaseDialog.Backdrop data-slot="drawer-backdrop" data-state={state} className="fixed inset-0 bg-background/70 backdrop-blur-subtle" render={<motion.div {...backdrop} />} /> <DialogProvider value={context}> <BaseDialog.Popup ref={setPanelRef} // Focus the sheet itself, so reading starts at the title and long content opens scrolled to the top. initialFocus={panelRef} data-slot="drawer" data-state={state} data-side={side} data-size={size} className={cn( "fixed flex flex-col overflow-hidden rounded-panel bg-surface-raised font-body text-14 text-foreground shadow-floating outline-none hairline", panelSides[side], panelSizes[side][size], className, )} render={ <motion.div {...panel} drag={swipeable && !reduced ? "y" : false} dragControls={dragControls} dragListener={false} dragConstraints={{ top: 0, bottom: 0 }} dragElastic={{ top: PULL_ELASTIC, bottom: 1 }} dragSnapToOrigin onDragEnd={onDragEnd} /> } {...rest} > {side === "bottom" && coarse ? ( <div data-slot="drawer-handle" aria-hidden="true" className="flex h-6 shrink-0 cursor-grab touch-none items-end justify-center" onPointerDown={(event) => swipeable && dragControls.start(event)} > <span className="h-1 w-9 rounded-pill bg-border-strong" /> </div> ) : null} <div data-slot="drawer-body" className={cn("flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain p-6", side === "bottom" && coarse && "pt-3")} > {children} </div> </BaseDialog.Popup> </DialogProvider> </BaseDialog.Portal> ) : null} </AnimatePresence> </BaseDialog.Root> ); }); /** The drawer's name, wired to aria-labelledby. Same part as DialogTitle. */ export const DrawerTitle = DialogTitle; /** Supporting sentence, wired to aria-describedby. Same part as DialogDescription. */ export const DrawerDescription = DialogDescription; /** The button row, pinned to the bottom while content scrolls. Same part as DialogActions. */ export const DrawerActions = DialogActions; /** A Button that closes the drawer. Same part as DialogClose. */ export const DrawerClose = DialogClose; - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/drawer.tsx. Needs @base-ui/react, motion, clsx, tailwind-merge.
Preview#
On a phone, flick the handle and the sheet leaves at the speed you threw it, on a spring that never bounces back.
Code#
"use client";
import {
forwardRef,
isValidElement,
useCallback,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type ComponentPropsWithoutRef,
type ReactElement,
type ReactNode,
} from "react";
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { AnimatePresence, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react";
import { cn } from "./lib/utils";
import { motionTokens, reducedTransition } from "./motion-tokens";
import {
DialogActions,
DialogClose,
DialogDescription,
DialogProvider,
DialogTitle,
dialogTriggerIsNativeButton,
type DialogContainer,
} from "./dialog";
export type DrawerSide = "right" | "left" | "bottom";
export type DrawerSize = "sm" | "md" | "lg";
export type DrawerProps = Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> & {
/** The drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions. */
children?: ReactNode;
/** Controlled open state. Pair with onOpenChange. */
open?: boolean;
/** Starting open state when uncontrolled. */
defaultOpen?: boolean;
/** Runs whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action. */
onOpenChange?: (open: boolean) => void;
/** The element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works. */
trigger?: ReactElement;
/** The edge the sheet slides from. */
side?: DrawerSide;
/** Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen). */
size?: DrawerSize;
/** Close on Esc, an outside press and, for bottom drawers on touch screens, a swipe down. */
dismissible?: boolean;
/** Where the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. */
container?: DialogContainer;
/** true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. */
modal?: boolean | "trap-focus";
};
const panelSides: Record<DrawerSide, string> = {
right: "inset-y-2 right-2 w-[calc(100%-1rem)]",
left: "inset-y-2 left-2 w-[calc(100%-1rem)]",
bottom: "inset-x-2 bottom-2 mx-auto max-w-2xl",
};
const panelSizes: Record<DrawerSide, Record<DrawerSize, string>> = {
right: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" },
left: { sm: "max-w-xs", md: "max-w-sm", lg: "max-w-lg" },
bottom: { sm: "max-h-[40%]", md: "max-h-[65%]", lg: "max-h-[90%]" },
};
/** How far off screen the sheet waits: past its own size plus the 8px inset. */
const offscreen: Record<DrawerSide, { x?: string; y?: string }> = {
right: { x: "110%" },
left: { x: "-110%" },
bottom: { y: "110%" },
};
/** A swipe closes the sheet past this share of its height, or faster than this many px/s. */
const SWIPE_DISTANCE = 0.3;
const SWIPE_VELOCITY = 600;
/** Upward pull resistance: the sheet gives about 10px before it stops. */
const PULL_ELASTIC = 0.04;
const coarseQuery = "(pointer: coarse)";
const subscribeCoarse = (onChange: () => void) => {
const list = window.matchMedia(coarseQuery);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
};
const readCoarse = () => window.matchMedia(coarseQuery).matches;
const readCoarseOnServer = () => false;
function usePointerCoarse() {
return useSyncExternalStore(subscribeCoarse, readCoarse, readCoarseOnServer);
}
export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(function Drawer(
{
children,
open: openProp,
defaultOpen = false,
onOpenChange,
trigger,
side = "right",
size = "md",
dismissible = true,
container,
modal,
className,
...rest
},
forwardedRef,
) {
const reduced = useReducedMotion() ?? false;
const coarse = usePointerCoarse();
const dragControls = useDragControls();
const [innerOpen, setInnerOpen] = useState(defaultOpen);
const open = openProp ?? innerOpen;
const generatedTriggerId = useId();
const triggerId = (isValidElement<{ id?: string }>(trigger) && trigger.props.id) || generatedTriggerId;
const cancelRef = useRef<HTMLButtonElement | null>(null);
const pendingRef = useRef(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const swipeable = side === "bottom" && coarse && dismissible;
const setOpen = useCallback(
(next: boolean) => {
setInnerOpen(next);
onOpenChange?.(next);
},
[onOpenChange],
);
const context = useMemo(
() => ({
variant: "default" as const,
close: () => setOpen(false),
cancelRef,
setPending: (pending: boolean) => {
pendingRef.current = pending;
},
}),
[setOpen],
);
const setPanelRef = useCallback(
(node: HTMLDivElement | null) => {
panelRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const onDragEnd = (event: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
const height = (event.target as HTMLElement | null)?.closest('[data-slot="drawer"]')?.getBoundingClientRect().height ?? 0;
if (info.offset.y > height * SWIPE_DISTANCE || info.velocity.y > SWIPE_VELOCITY) setOpen(false);
};
const backdrop = reduced
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: motionTokens.duration.medium, ease: motionTokens.ease.smoothOut } },
transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.smoothOut },
};
const panel = reduced
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
: {
initial: { opacity: 0, ...offscreen[side] },
animate: { opacity: 1, x: 0, y: 0 },
// The exit is a spring too, so a flick carries its velocity off screen.
exit: {
opacity: 0,
...offscreen[side],
transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.medium, ease: motionTokens.ease.exit } },
},
transition: { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter } },
};
const state = open ? "open" : "closed";
return (
<BaseDialog.Root
open={open}
triggerId={trigger ? triggerId : undefined}
modal={modal ?? (container !== undefined ? "trap-focus" : true)}
disablePointerDismissal={!dismissible}
onOpenChange={(next, details) => {
if (!next && (pendingRef.current || (!dismissible && details.reason === "escape-key"))) {
details.cancel();
return;
}
setOpen(next);
}}
>
{trigger ? <BaseDialog.Trigger id={triggerId} nativeButton={dialogTriggerIsNativeButton(trigger)} render={trigger} /> : null}
<AnimatePresence>
{open ? (
<BaseDialog.Portal keepMounted container={container}>
<BaseDialog.Backdrop
data-slot="drawer-backdrop"
data-state={state}
className="fixed inset-0 bg-background/70 backdrop-blur-subtle"
render={<motion.div {...backdrop} />}
/>
<DialogProvider value={context}>
<BaseDialog.Popup
ref={setPanelRef}
// Focus the sheet itself, so reading starts at the title and long content opens scrolled to the top.
initialFocus={panelRef}
data-slot="drawer"
data-state={state}
data-side={side}
data-size={size}
className={cn(
"fixed flex flex-col overflow-hidden rounded-panel bg-surface-raised font-body text-14 text-foreground shadow-floating outline-none hairline",
panelSides[side],
panelSizes[side][size],
className,
)}
render={
<motion.div
{...panel}
drag={swipeable && !reduced ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: PULL_ELASTIC, bottom: 1 }}
dragSnapToOrigin
onDragEnd={onDragEnd}
/>
}
{...rest}
>
{side === "bottom" && coarse ? (
<div
data-slot="drawer-handle"
aria-hidden="true"
className="flex h-6 shrink-0 cursor-grab touch-none items-end justify-center"
onPointerDown={(event) => swipeable && dragControls.start(event)}
>
<span className="h-1 w-9 rounded-pill bg-border-strong" />
</div>
) : null}
<div
data-slot="drawer-body"
className={cn("flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-contain p-6", side === "bottom" && coarse && "pt-3")}
>
{children}
</div>
</BaseDialog.Popup>
</DialogProvider>
</BaseDialog.Portal>
) : null}
</AnimatePresence>
</BaseDialog.Root>
);
});
/** The drawer's name, wired to aria-labelledby. Same part as DialogTitle. */
export const DrawerTitle = DialogTitle;
/** Supporting sentence, wired to aria-describedby. Same part as DialogDescription. */
export const DrawerDescription = DialogDescription;
/** The button row, pinned to the bottom while content scrolls. Same part as DialogActions. */
export const DrawerActions = DialogActions;
/** A Button that closes the drawer. Same part as DialogClose. */
export const DrawerClose = DialogClose;Usage#
Use it when
- Details of one row the person opens and closes while scanning a list: a run, a tool call, a customer.
- A form too long for a dialog that still belongs to the current page: agent settings, a schedule.
- On phones, actions and filters from the bottom edge where a thumb reaches them.
Use something else when
- A single decision: use a dialog.
- A few options anchored to a control: use a popover.
- Navigation that stays open: use a sidebar in the layout, not an overlay.
import { useState } from "react";
import { Button } from "@/components/pixilui/button";
import { Drawer, DrawerActions, DrawerDescription, DrawerTitle } from "@/components/pixilui/drawer";
// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
side: ["right", "bottom", "left"],
size: ["md", "sm", "lg"],
state: ["open", "closed"],
} as const;
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const steps = [
{ time: "09:41:02", text: "Read 48 open invoices from Stripe" },
{ time: "09:41:09", text: "Matched 46 to customers in HubSpot" },
{ time: "09:41:15", text: "Drafted reminders in the Q3 renewal template" },
{ time: "09:42:30", text: "Sent 46 reminders from billing@northwind.co" },
{ time: "09:42:31", text: "Skipped Arden Labs: no billing contact" },
{ time: "09:42:31", text: "Skipped Kova Health: invoice disputed" },
{ time: "09:42:40", text: "Logged the run to #billing-ops in Slack" },
];
export default function DrawerDemo({ side = "right", size = "md", state = "open" }: KnobValues<typeof knobs>) {
// The drawer portals into this stage, so it keeps the preview's theme and accent and stays inside it.
const [stage, setStage] = useState<HTMLDivElement | null>(null);
return (
<div ref={setStage} className="relative grid h-[460px] w-full max-w-3xl place-items-center overflow-hidden rounded-surface bg-surface hairline [contain:layout]">
<Drawer
key={`${side}-${size}-${state}`}
container={stage}
defaultOpen={state === "open"}
side={side}
size={size}
trigger={<Button variant="secondary">Open run 4821</Button>}
>
<DrawerTitle>Run 4821, Billing agent</DrawerTitle>
<DrawerDescription>Finished 2 minutes ago. 46 of 48 reminders sent.</DrawerDescription>
<ol className="mt-3 flex flex-col">
{steps.map((step, index) => (
<li key={index} className="flex gap-3 border-t border-border-subtle py-2.5 text-14">
<span className="numeric shrink-0 text-12 leading-5 text-muted">{step.time}</span>
<span className="min-w-0 text-foreground">{step.text}</span>
</li>
))}
</ol>
<DrawerActions confirm="Retry 2 reminders" onConfirm={() => wait(900)} labels={{ cancel: "Close" }} />
</Drawer>
</div>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The drawer's parts: DrawerTitle, DrawerDescription, your content, DrawerActions. | |
open | boolean | Controlled open state. Pair with onOpenChange. | |
defaultOpen | boolean | false | Starting open state when uncontrolled. |
onOpenChange | (open: boolean) => void | Runs whenever the drawer opens or closes, from the trigger, Esc, an outside press, a swipe or an action. | |
trigger | ReactElement | The element that opens the drawer, usually a Button. Rendered through Base UI's render prop, so any element works. | |
side | "right" | "left" | "bottom" | "right" | The edge the sheet slides from. |
size | "sm" | "md" | "lg" | "md" | Width for left and right (320, 384 or 512px); maximum height for bottom (40, 65 or 90% of the screen). |
dismissible | boolean | true | Close on Esc, an outside press and, for bottom drawers on touch screens, a swipe down. |
container | DialogContainer | Where the drawer portals to. Defaults to document.body. Pass an element (from a callback ref kept in state) to keep it inside a region; null waits for it. | |
modal | boolean | "trap-focus" | true traps focus, locks page scroll and blocks the page; "trap-focus" only traps focus. Defaults to true, or "trap-focus" when a container is set. |
Also takes every native <div> attribute except role, and a ref to it.
Keyboard#
- Enter
- On the trigger, opens the drawer and focuses the sheet, so reading starts at its title.
- Tab
- Moves between the controls in the drawer; focus stays inside while it is open.
- Esc
- Closes the drawer and returns focus to the trigger, unless dismissible is false or an action is pending.
Accessibility#
- Built on Base UI Dialog: role dialog, aria-modal, and the title and description wired to aria-labelledby and aria-describedby.
- The sheet itself takes focus on open, so a screen reader starts at the title instead of the footer button; Tab then moves through its controls.
- Focus is trapped while open and returns to the trigger on close; page scroll is locked for a full-page drawer.
- The drag handle is decorative (aria-hidden); keyboard and screen reader users close with Esc or the Close action.
- Swipe to dismiss only exists on coarse pointers, so a mouse never drags the sheet by accident.
Motion#
- The sheet slides in on the smooth spring, which never overshoots, and fades in on duration-fast.
- Closing uses the smooth spring too, so a flick down on the handle carries its velocity off screen.
- Pulling a bottom sheet up rubber-bands about 10px; letting go short of a third of its height springs it back.
- Reduced motion: the sheet and backdrop fade only, and dragging is off.
Responsive#
- Side sheets take the full width minus 16px on phones and cap at 320, 384 or 512px.
- Bottom sheets center at up to 672px wide and cap their height at 40, 65 or 90% of the screen.
- Content scrolls inside the sheet; DrawerActions stay pinned to its bottom.
- Bottom sheets show a drag handle on touch screens only.
Performance#
- Nothing is mounted while closed. Dragging runs on a motion value, outside React renders.
Notes for AI#
Also in the markdown page and llms.txt
- Same API as dialog: trigger, open, defaultOpen, onOpenChange, dismissible, container.
- Compose DrawerTitle, DrawerDescription, your content and DrawerActions; they are the dialog parts under drawer names.
- Use side bottom for mobile action sheets and side right for detail panels on desktop.
- Return a promise from DrawerActions onConfirm to get pending and error handling for free.