Shadcn Drawer for React and Tailwind
A sliding panel with swipe support.
Installation
bunx --bun shadcn@latest add https://kit.dev/r/drawer.jsonpnpm dlx shadcn@latest add https://kit.dev/r/drawer.jsonnpx shadcn@latest add https://kit.dev/r/drawer.jsonyarn shadcn@latest add https://kit.dev/r/drawer.json<Step>This component depends on Button and Scroll Area. Install them first if you haven't already.</Step>
Install the following dependencies:
bun add @ark-ui/react lucide-react tailwind-variantspnpm add @ark-ui/react lucide-react tailwind-variantsnpm install @ark-ui/react lucide-react tailwind-variantsyarn add @ark-ui/react lucide-react tailwind-variantsCopy and paste the following code into your project.
"use client";
import {
Drawer as ArkDrawer,
useDrawer as useArkDrawer,
useDrawerContext as useArkDrawerContext,
} from "@ark-ui/react/drawer";
import { ark } from "@ark-ui/react/factory";
import { Portal } from "@ark-ui/react/portal";
import { XIcon } from "lucide-react";
import React from "react";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { usePreviewFrame } from "@/registry/react/lib/preview-frame";
export const useDrawer = useArkDrawer;
export const useDrawerContext = useArkDrawerContext;
export const DrawerContext: typeof ArkDrawer.Context = ArkDrawer.Context;
interface DrawerModalContextProps {
/**
* Used internally to show or hide overlay
*
* @default true
*/
modal?: boolean;
}
const DrawerModalContext = React.createContext({} as DrawerModalContextProps);
export const DrawerProvider = (
props: React.ComponentProps<typeof ArkDrawer.Indent>
) => {
const { className, children, ...rest } = props;
return (
<ArkDrawer.Stack>
<ArkDrawer.IndentBackground
className={cn(
"[--indent-opacity:calc(0.1*(1-var(--drawer-swipe-progress,0)))]",
"fixed inset-0 z-50",
"bg-background",
"opacity-0",
"pointer-events-none",
"transition-opacity duration-300 ease-in",
"data-[state=open]:opacity-(--indent-opacity)",
"motion-reduce:transition-none!"
)}
data-slot="drawer-indent-background"
/>
<ArkDrawer.Indent
className={cn(
"[--indent-radius:calc(1rem*(1-var(--drawer-swipe-progress,0)))]",
"data-active:transform-[scale(calc(0.98+(0.02*var(--drawer-swipe-progress))))_translateY(calc(0.5rem*(1-var(--drawer-swipe-progress))))]",
"transition-[border-radius,transform] duration-300 ease-in-out will-change-transform",
"data-active:rounded-(--indent-radius)",
"motion-reduce:transition-none!",
className
)}
data-slot="drawer-indent"
{...rest}
>
{children}
</ArkDrawer.Indent>
</ArkDrawer.Stack>
);
};
export const Drawer = (props: React.ComponentProps<typeof ArkDrawer.Root>) => {
const {
modal = true,
lazyMount = true,
unmountOnExit = true,
preventScroll,
...rest
} = props;
const inPreview = usePreviewFrame();
return (
<DrawerModalContext.Provider value={{ modal }}>
<ArkDrawer.Root
data-slot="drawer"
lazyMount={lazyMount}
modal={modal}
preventScroll={preventScroll ?? (inPreview ? false : modal)}
unmountOnExit={unmountOnExit}
{...rest}
/>
</DrawerModalContext.Provider>
);
};
export const DrawerRootProvider = (
props: React.ComponentProps<typeof ArkDrawer.RootProvider>
) => {
const { lazyMount = true, unmountOnExit = true, ...rest } = props;
return (
<DrawerModalContext.Provider value={{ modal: true }}>
<ArkDrawer.RootProvider
data-slot="drawer"
lazyMount={lazyMount}
unmountOnExit={unmountOnExit}
{...rest}
/>
</DrawerModalContext.Provider>
);
};
export const DrawerTrigger = (
props: React.ComponentProps<typeof ArkDrawer.Trigger>
) => <ArkDrawer.Trigger data-slot="drawer-trigger" {...props} />;
const drawerOverlayVariants = tv({
base: [
"[--bg:rgb(0_0_0/calc(0.32*(1-var(--drawer-swipe-progress,0))))] [--blur:calc(4px*(1-var(--drawer-swipe-progress,0)))]",
"fixed inset-0 z-50",
"bg-(--bg) backdrop-blur-(--blur)",
"data-[has-nested=drawer]:pointer-events-none",
"duration-200",
"data-[state=open]:fade-in-0 data-[state=open]:animate-in",
"data-[state=closed]:fade-out-0 data-[state=closed]:animate-out",
"motion-reduce:animate-none!",
],
});
export const DrawerOverlay = (
props: React.ComponentProps<typeof ArkDrawer.Backdrop>
) => {
const { className, ...rest } = props;
const { modal } = _useDrawerModal();
if (!modal) {
return null;
}
return (
<ArkDrawer.Backdrop
className={cn(drawerOverlayVariants(), className)}
data-slot="drawer-backdrop"
{...rest}
/>
);
};
const drawerPositionerVariants = tv({
base: [
"[--bleed:--spacing(12)] [--inset:--spacing(0)]",
"fixed inset-0 z-50 overflow-hidden",
"flex w-screen items-end justify-center",
"data-[has-nested=drawer]:pointer-events-none",
"data-[swipe-direction=up]:items-start",
"data-[swipe-direction=left]:items-stretch data-[swipe-direction=left]:justify-start",
"data-[swipe-direction=right]:items-stretch data-[swipe-direction=right]:justify-end",
],
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "",
inset: [
"px-(--inset) sm:[--inset:--spacing(4)]",
"data-[swipe-direction=down]:pb-(--inset)",
"data-[swipe-direction=up]:pt-(--inset)",
"data-[swipe-direction=left]:pt-(--inset) data-[swipe-direction=left]:pb-(--inset)",
"data-[swipe-direction=right]:pt-(--inset) data-[swipe-direction=right]:pb-(--inset)",
],
},
},
});
interface DrawerPositionerProps
extends React.ComponentProps<typeof ArkDrawer.Positioner>,
VariantProps<typeof drawerPositionerVariants> {}
export const DrawerPositioner = (props: DrawerPositionerProps) => {
const { variant = "default", className, ...rest } = props;
return (
<ArkDrawer.Positioner
className={cn(drawerPositionerVariants({ variant }), className)}
data-slot="drawer-positioner"
{...rest}
/>
);
};
// ::after bleed — https://ark-ui.com/docs/components/drawer#preventing-overdrag-gaps
const drawerContentVariants = tv({
base: [
"[--space:--spacing(6)]",
"[--stack-peek:1.25rem]",
"[--stack-scale:calc(1-(var(--nested-drawers,0)*var(--stack-step)))] [--stack-step:0.05]",
"[--stack-height:calc(var(--drawer-frontmost-height,var(--drawer-height,0px))+var(--stack-peek))]",
"[interpolate-size:allow-keywords]",
"group/drawer",
"relative",
"touch-none",
"z-[calc(50+var(--layer-index,0))]",
"flex min-h-0 w-full flex-col",
"data-[swipe-direction=down]:max-h-[96svh]",
"data-[swipe-direction=up]:max-h-[96svh]",
"data-nested-drawer-open:h-(--stack-height)",
"data-nested-drawer-open:overflow-hidden",
"data-nested-drawer-open:pointer-events-none",
"bg-popover",
"text-popover-foreground",
"shadow-lg/5",
"outline-none",
"scale-(--stack-scale)",
"not-data-nested-drawer-open:transition-[transform,scale,opacity] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
"duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] data-nested-drawer-open:transition-[height,scale,translate]",
"data-swiping:select-none data-swiping:transition-none data-swiping:duration-0",
"data-dragging:transition-none",
"data-nested-drawer-swiping:duration-0",
"data-[swipe-direction=down]:origin-[center_bottom]",
"data-[swipe-direction=up]:origin-[center_top]",
"motion-reduce:animate-none! motion-reduce:transition-none!",
"after:pointer-events-none after:absolute after:bg-inherit after:content-['']",
"data-[swipe-direction=down]:rounded-t-2xl",
"data-[swipe-direction=down]:-mb-[max(0,calc(var(--drawer-snap-point-offset-y,0)+clamp(0,1,var(--drawer-snap-point-offset-y,0)/1px)*var(--drawer-swipe-movement-y,0)))]",
"data-[swipe-direction=down]:pb-[max(0px,calc(env(safe-area-inset-bottom,0px)+var(--drawer-snap-point-offset-y,0px)+clamp(0,1,var(--drawer-snap-point-offset-y,0px)/1px)*var(--drawer-swipe-movement-y,0px)))]",
"data-[swipe-direction=down]:after:inset-inline-0 data-[swipe-direction=down]:after:top-full data-[swipe-direction=down]:after:h-(--bleed)",
"data-[swipe-direction=down]:data-[state=open]:animate-drawer-slide-in-bottom",
"data-[swipe-direction=down]:data-[state=closed]:animate-drawer-slide-out-bottom",
"data-[swipe-direction=up]:rounded-b-2xl",
"data-[swipe-direction=up]:pt-[env(safe-area-inset-top,0)]",
"data-[swipe-direction=up]:after:inset-inline-0 data-[swipe-direction=up]:after:bottom-full data-[swipe-direction=up]:after:h-(--bleed)",
"data-[swipe-direction=up]:data-[state=open]:animate-drawer-slide-in-top",
"data-[swipe-direction=up]:data-[state=closed]:animate-drawer-slide-out-top",
"data-[swipe-direction=left]:h-full data-[swipe-direction=left]:max-h-none data-[swipe-direction=left]:min-h-0 data-[swipe-direction=left]:w-full data-[swipe-direction=left]:max-w-md",
"data-[swipe-direction=left]:rounded-e-2xl",
"data-[swipe-direction=left]:ps-[env(safe-area-inset-left,0)]",
"data-[swipe-direction=left]:after:inset-block-0 data-[swipe-direction=left]:after:inset-e-full data-[swipe-direction=left]:after:inset-inline-auto data-[swipe-direction=left]:after:h-auto data-[swipe-direction=left]:after:w-(--bleed)",
"data-[swipe-direction=left]:data-[state=open]:animate-drawer-slide-in-left",
"data-[swipe-direction=left]:data-[state=closed]:animate-drawer-slide-out-left",
"data-[swipe-direction=right]:h-full data-[swipe-direction=right]:max-h-none data-[swipe-direction=right]:min-h-0 data-[swipe-direction=right]:w-full data-[swipe-direction=right]:max-w-md",
"data-[swipe-direction=right]:rounded-s-2xl",
"data-[swipe-direction=right]:pe-[env(safe-area-inset-right,0)]",
"data-[swipe-direction=right]:after:inset-block-0 data-[swipe-direction=right]:after:inset-inline-auto data-[swipe-direction=right]:after:inset-s-full data-[swipe-direction=right]:after:h-auto data-[swipe-direction=right]:after:w-(--bleed)",
"data-[swipe-direction=right]:data-[state=open]:animate-drawer-slide-in-right",
"data-[swipe-direction=right]:data-[state=closed]:animate-drawer-slide-out-right",
],
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default: "",
inset: [
"sm:rounded-2xl sm:border",
"sm:**:data-[slot=drawer-footer]:rounded-b-[calc(var(--radius-2xl)-1px)]",
],
},
},
});
type SnapPoint = number | string;
function needsFullHeightForSnapPoints(snapPoints: SnapPoint[]): boolean {
if (snapPoints.length !== 1) {
return true;
}
return snapPoints[0] !== 1;
}
interface DrawerContentProps
extends React.ComponentProps<typeof ArkDrawer.Content>,
VariantProps<typeof drawerContentVariants> {
/**
* Show the drag bar indicator
*
* @default true
*/
showBar?: boolean;
/**
* Show close button at the top right corner
*
* @default false
*/
showCloseButton?: boolean;
}
export const DrawerContent = (props: DrawerContentProps) => {
const {
variant = "default",
showBar,
showCloseButton = false,
className,
children,
...rest
} = props;
return (
<Portal>
<DrawerOverlay />
<DrawerContext>
{({ snapPoints, swipeDirection }) => {
const isVertical =
swipeDirection === "down" || swipeDirection === "up";
const fullHeight =
isVertical && needsFullHeightForSnapPoints(snapPoints);
return (
<DrawerPositioner variant={variant}>
<ArkDrawer.Content
className={cn(
drawerContentVariants({ variant }),
fullHeight && "h-full",
className
)}
data-slot="drawer-content"
{...rest}
>
<DrawerGrabber show={showBar} />
{children}
{!!showCloseButton && (
<DrawerClose asChild>
<Button
aria-label="Close"
className="absolute inset-e-4 top-4 opacity-64 hover:opacity-100 group-data-[swipe-direction=up]/drawer:top-[calc(1rem+env(safe-area-inset-top,0))]"
size="icon-sm"
variant="ghost"
>
<XIcon aria-hidden="true" />
</Button>
</DrawerClose>
)}
</ArkDrawer.Content>
</DrawerPositioner>
);
}}
</DrawerContext>
</Portal>
);
};
interface DrawerGrabberProps
extends React.ComponentProps<typeof ArkDrawer.Grabber> {
/**
* Whether to render the grabber for top and bottom drawers
*
* @default true
*/
show?: boolean;
}
export const DrawerGrabber = (props: DrawerGrabberProps) => {
const { show = true, className, ...rest } = props;
if (!show) {
return null;
}
return (
<ArkDrawer.Grabber
className={cn(
"hidden shrink-0 cursor-grab touch-none select-none active:cursor-grabbing",
"group-data-[swipe-direction=down]/drawer:flex group-data-[swipe-direction=down]/drawer:w-full group-data-[swipe-direction=down]/drawer:items-center group-data-[swipe-direction=down]/drawer:justify-center group-data-[swipe-direction=down]/drawer:py-5",
"group-data-[swipe-direction=up]/drawer:z-10 group-data-[swipe-direction=up]/drawer:order-last group-data-[swipe-direction=up]/drawer:flex group-data-[swipe-direction=up]/drawer:w-full group-data-[swipe-direction=up]/drawer:items-center group-data-[swipe-direction=up]/drawer:justify-center group-data-[swipe-direction=up]/drawer:py-5",
"group-data-nested-drawer-open/drawer:hidden",
className
)}
data-slot="drawer-grabber"
{...rest}
>
<ArkDrawer.GrabberIndicator
className="h-1 w-10 rounded-full bg-muted-foreground/32 group-hover/drawer:bg-muted-foreground/48"
data-slot="drawer-grabber-indicator"
/>
</ArkDrawer.Grabber>
);
};
interface DrawerHeaderProps extends React.ComponentProps<typeof ark.div> {
/**
* The description of the drawer
*/
description?: string;
/**
* The title of the drawer
*/
title?: string;
}
const drawerHeaderVariants = tv({
base: [
"shrink-0",
"flex flex-col gap-2 text-center",
"p-(--space)",
"in-[[data-slot=drawer-content]:has([data-slot=drawer-body])]:pb-3",
"group-data-[swipe-direction=down]/drawer:pt-0",
],
});
export const DrawerHeader = (props: DrawerHeaderProps) => {
const { className, title, description, children, ...rest } = props;
return (
<ark.div
className={cn(drawerHeaderVariants(), className)}
data-slot="drawer-header"
{...rest}
>
{!!title && <DrawerTitle>{title}</DrawerTitle>}
{!!description && <DrawerDescription>{description}</DrawerDescription>}
{!title && typeof children === "string" ? (
<DrawerTitle>{children}</DrawerTitle>
) : (
children
)}
</ark.div>
);
};
export const DrawerTitle = (
props: React.ComponentProps<typeof ArkDrawer.Title>
) => {
const { className, ...rest } = props;
return (
<ArkDrawer.Title
className={cn(
"text-center font-semibold text-lg leading-none",
className
)}
data-slot="drawer-title"
{...rest}
/>
);
};
export const DrawerDescription = (
props: React.ComponentProps<typeof ArkDrawer.Description>
) => {
const { className, ...rest } = props;
return (
<ArkDrawer.Description
className={cn("text-center text-muted-foreground text-sm", className)}
data-slot="drawer-description"
{...rest}
/>
);
};
interface DrawerBodyProps extends React.ComponentProps<typeof ark.div> {
/**
* Add a fade effect to the scroll area
*
* @default false
*/
scrollFade?: boolean;
}
export const DrawerBody = (props: DrawerBodyProps) => {
const { scrollFade = false, className, ...rest } = props;
return (
<ScrollArea className="min-h-0 flex-1 touch-pan-y" scrollFade={scrollFade}>
<ark.div
className={cn(
"p-(--space) text-center",
"in-[[data-slot=drawer-content]:has([data-slot=drawer-header])]:pt-0",
"group-data-[swipe-direction=down]/drawer:in-[[data-slot=drawer-content]:not(:has([data-slot=drawer-header]))]:pt-0",
"in-[[data-slot=drawer-content]:has([data-slot=drawer-footer]:not(.border-t))]:pb-1",
className
)}
data-slot="drawer-body"
{...rest}
/>
</ScrollArea>
);
};
export const DrawerClose = (
props: React.ComponentProps<typeof ArkDrawer.CloseTrigger>
) => <ArkDrawer.CloseTrigger data-slot="drawer-close" {...props} />;
const drawerFooterVariants = tv({
base: [
"shrink-0",
"flex flex-col-reverse gap-2",
"sm:rounded-none",
"px-(--space) py-4",
],
defaultVariants: {
variant: "default",
},
variants: {
variant: {
bare: "",
default: "border-t bg-muted/48",
},
},
});
interface DrawerFooterProps
extends React.ComponentProps<typeof ark.div>,
VariantProps<typeof drawerFooterVariants> {}
export const DrawerFooter = (props: DrawerFooterProps) => {
const { variant = "default", className, ...rest } = props;
return (
<ark.div
className={cn(drawerFooterVariants({ variant }), className)}
data-slot="drawer-footer"
{...rest}
/>
);
};
const _useDrawerModal = () => {
const context = React.useContext(DrawerModalContext);
if (!context) {
throw new Error("useDrawerModal must be used within a Drawer");
}
return context;
};Update the import paths to match your project setup.
Anatomy
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerOverlay (built in)
├── DrawerPositioner (built in)
├── DrawerGrabber (built in)
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
├── DrawerBody
├── DrawerFooter
└── DrawerCloseshadcn.io wraps Ark UI Drawer. Overlay, positioner, and grabber are created by DrawerContent. useDrawer is the machine hook for DrawerRootProvider; useDrawerContext / DrawerContext is in-tree.
lazyMount and unmountOnExit default to true (Ark: false). preventScroll follows modal, and is forced off inside docs previews. Default swipe direction is down (bottom sheet).
For a desktop side panel, use Sheet. For a centered modal, use Dialog.
Usage
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTrigger,
} from "@/components/ui/drawer";<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader
description="Supporting description."
title="Drawer title"
/>
<DrawerBody>Content</DrawerBody>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Controlled
Control open state with open and onOpenChange. { open: boolean }.
Root Provider
Use useDrawer with DrawerRootProvider when you need the API outside the tree. Pass machine options (snapPoints, swipeDirection, modal, …) to useDrawer(), not to the provider.
Title & Description
DrawerHeader supports two usage patterns:
Using props
Pass title and description props directly to DrawerHeader.
<DrawerHeader
description="Do you want to allow the USB accessory to connect to this device?"
title="Allow accessory to connect?"
/>This approach does not require DrawerTitle or DrawerDescription components.
Using components
Use DrawerTitle and DrawerDescription as children for more control.
<DrawerHeader>
<DrawerTitle>Allow accessory to connect?</DrawerTitle>
<DrawerDescription>
Do you want to allow the USB accessory to connect to this device?
</DrawerDescription>
</DrawerHeader>A title is required for accessibility. Use className="sr-only" on DrawerTitle when it should be visually hidden.
Examples
Inset variant
Use variant="inset" on DrawerContent so the drawer appears as a floating card rather than edge-to-edge.
Swipe directions
swipeDirection is down (default), up, start, or end. start / end follow text direction (left / right in LTR).
Snap points
Use snapPoints to snap to multiple heights. Numbers are fractions of the viewport (0.5 is 50%).
Scrollable
Long content goes in DrawerBody. preventDragOnScroll (default true) keeps scrolling from starting a swipe.
Non-modal
Use modal={false} to allow interaction with the page behind the drawer.
Close button
Pass showCloseButton to DrawerContent for a corner close control (aria-label="Close").
No drag area
Apply data-no-drag to any element inside the drawer to prevent dragging from starting there.
Non-draggable
Set draggable={false} on DrawerContent to disable drag-to-dismiss on the panel. The grabber can still drag.
Custom spacing
--space is internal padding (default --spacing(6)). --bleed is the overdrag continuation (default --spacing(12)).
<DrawerContent className="[--space:--spacing(8)]" />Multiple triggers
Share one drawer across triggers. Pass value on each DrawerTrigger and handle onTriggerValueChange.
Indent
Wrap the page in DrawerProvider so the background scales and rounds while the drawer is open. Use modal={false} so the indented page stays visible.
Context
Read open and drag state with DrawerContext or useDrawerContext.
Guides
Drawer vs Sheet vs Dialog
| Drawer | Sheet | Dialog | |
|---|---|---|---|
| Motion | Swipe from an edge | Slide from an edge | Centered |
| Typical use | Mobile sheet, snap points | Desktop side panel | Modal task |
| Drag to dismiss | Yes | No | No |
Swipe direction
data-swipe-direction on content is the physical edge (up | down | left | right). swipeDirection="start" / "end" on the root map through dir.
Conditional rendering
Keep Drawer mounted and control it with open / onOpenChange. shadcn.io already sets lazyMount and unmountOnExit so portal content leaves the DOM while closed.
API Reference
shadcn.io wraps Ark UI Drawer. Defaults below are shadcn.io values. lazyMount and unmountOnExit default to true (Ark: false). preventScroll follows modal, and is forced off inside docs previews.
asChild merges props onto a single child element.
Drawer
Root. Overlay and positioner are created by DrawerContent.
| Prop | Type | Default | Description |
|---|---|---|---|
closeOnEscape | boolean | true | Close when Escape is pressed. |
closeOnInteractOutside | boolean | true | Close when the outside is clicked. |
closeThreshold | number | 0.25 | Fraction of size needed to dismiss on swipe. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
defaultSnapPoint | number | string | null | 1 | Uncontrolled initial snap point. |
defaultTriggerValue | string | - | Uncontrolled initial active trigger value. |
finalFocusEl | () => MaybeElement | - | Element to focus when the drawer closes. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-closed content. activity needs React 19+. |
id | string | - | Unique id for the machine. |
ids | Partial<{ backdrop: string; positioner: string; content: string; title: string; description: string; header: string; trigger: string | ((value?: string) => string); grabber: string; grabberIndicator: string; closeTrigger: string; swipeArea: string }> | - | Element ids for composition. |
immediate | boolean | - | Apply presence changes immediately instead of the next frame. |
initialFocusEl | () => MaybeElement | - | Element to focus when the drawer opens. |
lazyMount | boolean | true | Mount content on first open. |
modal | boolean | true | Trap pointer events and hide content behind the drawer. |
onEscapeKeyDown | (event: KeyboardEvent) => void | - | Called when Escape is pressed. |
onExitComplete | () => void | - | Called when the close animation finishes. |
onFocusOutside | (event: FocusOutsideEvent) => void | - | Called when focus moves outside. |
onInteractOutside | (event: InteractOutsideEvent) => void | - | Called on outside interaction. |
onOpenChange | (details: OpenChangeDetails) => void | - | { open: boolean }. |
onPointerDownOutside | (event: PointerDownOutsideEvent) => void | - | Called on pointer down outside. |
onRequestDismiss | (event: LayerDismissEvent) => void | - | Called when a parent layer dismisses this one. |
onSnapPointChange | (details: SnapPointChangeDetails) => void | - | Called when the snap point changes. |
onTriggerValueChange | (details: TriggerValueChangeDetails) => void | - | { value: string | null }. |
open | boolean | - | Controlled open state. |
present | boolean | - | Controlled presence. |
preventDragOnScroll | boolean | true | Do not start a drag from a scrollable child. |
preventScroll | boolean | true when modal | Prevent scrolling behind the drawer. Off in docs previews. |
restoreFocus | boolean | true | Restore focus to the previously focused element. |
role | "dialog" | "alertdialog" | "dialog" | Dialog role. |
skipAnimationOnMount | boolean | false | Skip the initial presence animation. |
snapPoint | number | string | null | - | Controlled snap point. |
snapPoints | (number | string)[] | [1] | Snap points. Numbers are viewport fractions. |
snapToSequentialPoints | boolean | false | Snap only to the next point when swiping. |
stack | DrawerStack | - | External stack store for indent visuals. |
swipeDirection | "up" | "down" | "start" | "end" | "down" | Edge the drawer slides from. start / end follow dir. |
swipeVelocityThreshold | number | 700 | Velocity in px/s that dismisses the drawer. |
trapFocus | boolean | true | Trap focus inside the drawer. |
triggerValue | string | - | Controlled active trigger value. |
unmountOnExit | boolean | true | Unmount content after the close animation. |
| Attribute | Description |
|---|---|
data-slot | drawer |
data-scope | drawer |
data-part | root |
DrawerTrigger
Opens the drawer. Renders a button.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the trigger. |
value | string | - | Id for this trigger when several share one drawer. |
| Attribute | Description |
|---|---|
data-slot | drawer-trigger |
data-scope | drawer |
data-part | trigger |
data-value | The trigger value |
data-state | "open" or "closed" |
data-current | Present when this trigger is the active one |
DrawerContent
Portaled panel. Includes overlay, positioner, and grabber. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "inset" | "default" | Edge-to-edge, or padded floating card. |
showBar | boolean | true | Show the grabber on top and bottom drawers. |
showCloseButton | boolean | false | Show a corner close button (aria-label="Close"). |
draggable | boolean | true | If false, only the grabber can drag. |
asChild | boolean | false | Merge onto a single child of the panel. |
className | string | - | Class names on the panel. |
| Attribute | Description |
|---|---|
data-slot | drawer-content |
data-scope | drawer |
data-part | content |
data-state | "open" or "closed" |
data-swipe-direction | "up", "down", "left", or "right" |
data-swiping | Present while swiping |
data-dragging | Present while dragging |
data-expanded | Present when expanded |
data-nested-drawer-open | Present when a nested drawer is open |
| CSS variable | Default | Description |
|---|---|---|
--space | --spacing(6) | Internal padding. |
--bleed | --spacing(12) | Overdrag continuation beyond the panel. |
--drawer-height | measured | Height of the panel. |
--drawer-translate-x / --drawer-translate-y | 0 | Drag translation. |
--layer-index | stack | Dismissable layer index. |
DrawerHeader
Header for title and description. Pass title / description or compose DrawerTitle and DrawerDescription.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | Renders DrawerTitle. |
description | string | - | Renders DrawerDescription. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the header. |
| Attribute | Description |
|---|---|
data-slot | drawer-header |
DrawerTitle
Accessible title. Renders an h2.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the title. |
| Attribute | Description |
|---|---|
data-slot | drawer-title |
data-scope | drawer |
data-part | title |
DrawerDescription
Accessible description. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the description. |
| Attribute | Description |
|---|---|
data-slot | drawer-description |
data-scope | drawer |
data-part | description |
DrawerBody
Scrollable body. Uses Scroll Area.
| Prop | Type | Default | Description |
|---|---|---|---|
scrollFade | boolean | false | Fade at the scroll edges. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the body. |
| Attribute | Description |
|---|---|
data-slot | drawer-body |
DrawerFooter
Footer for actions. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "bare" | "default" | default has a top border and muted background. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the footer. |
| Attribute | Description |
|---|---|
data-slot | drawer-footer |
DrawerClose
Closes the drawer. Renders a button.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the close control. |
| Attribute | Description |
|---|---|
data-slot | drawer-close |
data-scope | drawer |
data-part | close-trigger |
DrawerGrabber
Drag handle. Created by DrawerContent for up / down drawers. Hidden on side drawers and while a nested drawer is open.
| Prop | Type | Default | Description |
|---|---|---|---|
show | boolean | true | Render the grabber. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the grabber. |
| Attribute | Description |
|---|---|
data-slot | drawer-grabber |
data-scope | drawer |
data-part | grabber |
DrawerProvider
App-level indent wrapper (Drawer.Stack + background + indent). Wrap the page (or a preview) so content scales while a drawer is open.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the indent surface. |
| Attribute | Description |
|---|---|
data-slot | drawer-indent |
DrawerRootProvider
Root alternative that takes the API from useDrawer. Presence (lazyMount, unmountOnExit) can still be set on the provider. Pass modal, snapPoints, and swipeDirection to useDrawer().
| Prop | Type | Default | Description |
|---|---|---|---|
value | UseDrawerReturn | required | Return value of useDrawer(). |
lazyMount | boolean | true | Mount content on first open. |
unmountOnExit | boolean | true | Unmount after the close animation. |
| Attribute | Description |
|---|---|
data-slot | drawer |
useDrawer
Creates the drawer API for DrawerRootProvider. Same options as Drawer except layout-only props.
const drawer = useDrawer({
snapPoints: [0.25, 0.5, 1],
defaultSnapPoint: 0.5,
});
drawer.setOpen(true);
drawer.setSnapPoint(1);DrawerContext / useDrawerContext
Render-prop or hook access to drawer state. Use inside Drawer or DrawerRootProvider.
| Property | Type | Description |
|---|---|---|
open | boolean | Whether the drawer is open. |
setOpen | (open: boolean) => void | Open or close. |
dragging | boolean | Whether the drawer is being dragged. |
triggerValue | string | null | Active trigger value. |
setTriggerValue | (value: string | null) => void | Set the active trigger. |
snapPoints | (number | string)[] | Configured snap points. |
snapPoint | number | string | null | Active snap point. |
setSnapPoint | (snapPoint: number | string | null) => void | Set the snap point. |
swipeDirection | "up" | "down" | "start" | "end" | Configured swipe direction. |
getOpenPercentage | () => number | Open amount from 0 to 1. |
getSnapPointIndex | () => number | Index of the active snap point. |
getContentSize | () => number | null | Main-axis size of the panel. |
DrawerContext children: (context) => ReactNode.
Accessibility
Complies with the Dialog WAI-ARIA design pattern. Always include DrawerTitle (or title on DrawerHeader). Use className="sr-only" when the title should be visually hidden.
Keyboard support
| Key | Description |
|---|---|
Enter / Space | When focus is on the trigger, opens the drawer. |
Tab | Moves focus to the next focusable element. Focus is trapped while open. |
Shift + Tab | Moves focus to the previous focusable element. |
Escape | Closes the drawer and returns focus to the trigger (or finalFocusEl). |