Shadcn Drawer
Free shadcn/ui drawer component for React — the drag-to-dismiss panel built on vaul. Snap points, nested drawers, four directions, and the responsive Dialog-on-desktop / Drawer-on-mobile pattern. The examples official shadcn never shipped.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
Props
direction
= "bottom"
"top" | "right" | "bottom" | "left"
Which edge the drawer slides in from (on Drawer).
snapPoints
= —
(number | string)[]
Heights the drawer snaps to, e.g. [0.4, 1]. Pair with activeSnapPoint / setActiveSnapPoint.
shouldScaleBackground
= false
boolean
Scales the page behind the drawer for the iOS-style stacked look (on Drawer).
open
= —
boolean
Controlled open state (on Drawer). Pair with onOpenChange.
Ten parts (Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerOverlay, DrawerPortal) built on vaul — the drag-to-dismiss drawer by emilkowalski. DrawerContent renders a drag handle for bottom drawers and is direction-aware (top/right/bottom/left). For nested drawers use vaul's NestedRoot directly. Animations require tw-animate-css.
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
Examples
Basic
A bottom drawer with a drag handle, header, and footer — drag down or press Escape to dismiss.
Packages
Props
No props documented yet.
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerDemo01() {
return (
<Drawer>
<DrawerTrigger className="inline-flex items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground">
Open drawer
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>
This action cannot be undone. Drag the handle down or press Escape
to dismiss.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<button className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90">
Submit
</button>
<DrawerClose className="inline-flex items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground">
Cancel
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}
Responsive (Dialog + Drawer)
A **Dialog on desktop** and a **Drawer on mobile** via `useMediaQuery`, sharing one form — the canonical shadcn responsive pattern.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false)
React.useEffect(() => {
const result = window.matchMedia(query)
const onChange = () => setMatches(result.matches)
onChange()
result.addEventListener("change", onChange)
return () => result.removeEventListener("change", onChange)
}, [query])
return matches
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
return (
<form className={cn("grid items-start gap-4", className)}>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" defaultValue="[email protected]" />
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@ada" />
</div>
<Button type="submit">Save changes</Button>
</form>
)
}
export default function DrawerResponsive01() {
const [open, setOpen] = React.useState(false)
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're
done.
</DialogDescription>
</DialogHeader>
<ProfileForm />
</DialogContent>
</Dialog>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>
Make changes to your profile here. Click save when you're done.
</DrawerDescription>
</DrawerHeader>
<ProfileForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
Directions
Open from any edge — `top`, `right`, `bottom`, or `left` — with the `direction` prop on `Drawer`.
Packages
Props
No props documented yet.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const directions = ["top", "right", "bottom", "left"] as const
export default function DrawerDirections01() {
return (
<div className="flex flex-wrap items-center gap-3">
{directions.map((direction) => (
<Drawer key={direction} direction={direction}>
<DrawerTrigger asChild>
<Button variant="outline" className="capitalize">
{direction}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="capitalize">{direction} drawer</DrawerTitle>
<DrawerDescription>
This drawer slides in from the {direction}. Set it with the{" "}
<code>direction</code> prop on <code>Drawer</code>.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
)
}
Snap points
A drawer that opens at 40% height and snaps to full when dragged — vaul `snapPoints={[0.4, 1]}`.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const snapPoints = [0.4, 1]
export default function DrawerSnap01() {
const [snap, setSnap] = React.useState<number | string | null>(snapPoints[0])
return (
<Drawer
snapPoints={snapPoints}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
>
<DrawerTrigger asChild>
<Button variant="outline">Open snap drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drag to expand</DrawerTitle>
<DrawerDescription>
This drawer opens at 40% height. Drag the handle up to snap it to
full height.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<p className="text-sm text-muted-foreground">
Active snap point:{" "}
<span className="font-medium text-foreground">
{snap === 1 ? "full" : "40%"}
</span>
</p>
<div className="mt-4 grid gap-3">
{Array.from({ length: 6 }, (_, i) => i + 1).map((n) => (
<div
key={n}
className="rounded-md border border-border p-3 text-sm"
>
Row {n} — scroll appears once the drawer is at full height.
</div>
))}
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
Nested drawers
Stack a second drawer on the first with vaul `NestedRoot` — the one behind scales back to show the stack.
Packages
Props
No props documented yet.
"use client"
// Nested drawers use vaul's NestedRoot, which our primitive intentionally does
// not re-export (to stay byte-faithful to shadcn/ui). Importing it directly is
// the documented vaul pattern for stacking one drawer on top of another.
import { Drawer as DrawerPrimitive } from "vaul"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerNested01() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open drawer</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>First drawer</DrawerTitle>
<DrawerDescription>
Open a nested drawer to stack a second panel on top — the one
behind scales back to reveal the stack.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerPrimitive.NestedRoot>
<DrawerTrigger asChild>
<Button>Open nested drawer</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Nested drawer</DrawerTitle>
<DrawerDescription>
Closing this returns you to the first drawer.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</DrawerPrimitive.NestedRoot>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}
Scrollable
A sticky header and footer with a scrollable body — `flex-1 overflow-y-auto` for long content.
Packages
Props
No props documented yet.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const events = Array.from({ length: 20 }, (_, i) => i + 1)
export default function DrawerScroll01() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">View activity</Button>
</DrawerTrigger>
<DrawerContent className="max-h-[80vh]">
<DrawerHeader className="border-b">
<DrawerTitle>Activity</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto grid w-full max-w-md gap-3">
{events.map((n) => (
<div
key={n}
className="rounded-md border border-border p-3 text-sm"
>
<p className="font-medium">Event #{n}</p>
<p className="text-muted-foreground">
Something happened a little while ago.
</p>
</div>
))}
</div>
</div>
<DrawerFooter className="border-t">
<DrawerClose asChild>
<Button>Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
With a form
A **controlled** drawer holding a payment form that closes itself on submit via `setOpen(false)`.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function DrawerForm01() {
const [open, setOpen] = React.useState(false)
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="outline">Add payment method</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Add payment method</DrawerTitle>
<DrawerDescription>
Enter your card details. They are saved when you submit.
</DrawerDescription>
</DrawerHeader>
<form
onSubmit={(event) => {
event.preventDefault()
setOpen(false)
}}
className="grid gap-4 px-4"
>
<div className="grid gap-2">
<Label htmlFor="card-name">Name on card</Label>
<Input id="card-name" defaultValue="Ada Lovelace" />
</div>
<div className="grid gap-2">
<Label htmlFor="card-number">Card number</Label>
<Input id="card-number" defaultValue="4242 4242 4242 4242" />
</div>
<DrawerFooter className="px-0">
<Button type="submit">Save card</Button>
<DrawerClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</form>
</div>
</DrawerContent>
</Drawer>
)
}
Command menu
A mobile **⌘K** — a [Command](/components/command) palette inside a drawer that closes when an item is picked.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import {
CalendarIcon,
SearchIcon,
SettingsIcon,
SmileIcon,
UserIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerCommand01() {
const [open, setOpen] = React.useState(false)
const close = () => setOpen(false)
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="outline">
<SearchIcon />
Search…
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="sr-only">
<DrawerTitle>Command menu</DrawerTitle>
</DrawerHeader>
<Command className="rounded-none border-t">
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem onSelect={close}>
<CalendarIcon />
Calendar
</CommandItem>
<CommandItem onSelect={close}>
<SmileIcon />
Search emoji
</CommandItem>
</CommandGroup>
<CommandGroup heading="Settings">
<CommandItem onSelect={close}>
<UserIcon />
Profile
</CommandItem>
<CommandItem onSelect={close}>
<SettingsIcon />
Settings
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</DrawerContent>
</Drawer>
)
}
The Shadcn Drawer is the drag-to-dismiss panel — the canonical shadcn/ui drawer built on vaul, with a drag handle, snap points, four directions, and nested stacking. Official ships it with a couple of demos; the examples below cover what you actually build on mobile — snap-point sheets, nested flows, command menus, and the responsive Dialog-on-desktop / Drawer-on-mobile pattern.
Built on vaul
The Drawer is a thin, styled wrapper over vaul, Emil Kowalski's React drawer library. Vaul owns the hard parts — the drag-to-dismiss gesture, momentum and velocity, snap points, background scaling, and nested drawer stacking — so the shadcn layer only adds Tailwind styling and your design tokens. Install it once:
npm install vaul
Everything below — directions, snap points, nesting — is a vaul feature surfaced through the ten-part shadcn API (Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerClose, DrawerOverlay, DrawerPortal).
Drawer vs Sheet vs Dialog
All three are overlays; the choice is about input model and placement:
- Drawer (vaul) is touch-native — drag-to-dismiss, momentum, snap points. Best on mobile and for bottom sheets.
- Sheet (Radix) slides from any edge without drag physics — the better desktop side panel.
- Dialog is a centered modal for a single focused task.
The canonical real-world setup is a Dialog or Sheet on desktop and a Drawer on mobile for the same content — covered next.
Responsive: Dialog on desktop, Drawer on mobile
This is the pattern shadcn documents for the Drawer, and it's the single most useful one. Detect the viewport with a small useMediaQuery hook and render a Dialog above your breakpoint or a Drawer below it, reusing the same form in both:
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) return <Dialog>…<ProfileForm /></Dialog>
return <Drawer>…<ProfileForm /></Drawer>
The Responsive example is the complete, copy-pasteable version with the hook included.
Directions
Pass direction to Drawer — "top", "right", "bottom", or "left" (default "bottom"). DrawerContent is direction-aware: bottom and top drawers get the rounded edge and the drag handle, while left and right drawers take a width (sm:max-w-sm) and fill the height. The Directions example shows all four from one row.
Snap points
Snap points turn the drawer into a multi-stage sheet — a peek state that expands to full. Pass snapPoints (fractions like 0.4 or CSS values like "300px") and control the active stop with activeSnapPoint / setActiveSnapPoint:
<Drawer snapPoints={[0.4, 1]} activeSnapPoint={snap} setActiveSnapPoint={setSnap}>
The drawer opens at the first stop and the user drags between them; content past the visible height scrolls once expanded. No competitor ships this — see the Snap points example.
Nested drawers
Vaul can stack drawers: opening a second one scales the first back to reveal the stack. Use vaul's NestedRoot for the inner drawer (our primitive stays byte-faithful to shadcn and doesn't re-export it, so import it directly):
import { Drawer as DrawerPrimitive } from "vaul"
// …
<DrawerPrimitive.NestedRoot>{/* inner DrawerContent */}</DrawerPrimitive.NestedRoot>
The Nested drawers example shows a two-level flow.
Scrollable content & forms
For long content, give DrawerContent a max-h-[80vh] (already the default for bottom drawers) and a flex-1 overflow-y-auto middle region so the header and footer stay put while the body scrolls. For input, control the open state and call setOpen(false) on submit — the Scrollable and With-a-form examples cover both. You can even drop a Command palette inside a drawer for a mobile ⌘K, as the Command menu example does.
Background scaling & controlling
Vaul can scale the page behind the drawer for the iOS-style stacked look — set shouldScaleBackground on Drawer (and wrap your app in a [vaul-drawer-wrapper] element). Open declaratively with DrawerTrigger, or controlled from state via open / onOpenChange to open it from anywhere or close it after an action.
Accessibility
Vaul builds on an accessible dialog foundation — it traps focus, restores it to the trigger on close, and closes on Escape and outside-click. Always include a DrawerTitle (and ideally a DrawerDescription); if your design hides the title, keep it in the DOM with sr-only so screen readers still announce the drawer, as the Command menu example does.
Theming
The drawer reads your design tokens — --background, --border, --muted (the drag handle) — so it follows your theme, including dark mode, with no overrides. Style any region with a className to tune padding, borders, or width per use.