Drawer
A touch-first panel that slides from an edge, follows the finger, rests on snap points and dismisses with a swipe.
"use client"
import * as React from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerDemo() {
const [goal, setGoal] = React.useState(350)
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
Set daily goal
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Daily move goal</DrawerTitle>
<DrawerDescription>
Set how many active calories you want to burn each day.
</DrawerDescription>
</DrawerHeader>
<div className="flex items-center justify-center gap-6 px-6 py-6">
<Button
variant="outline"
size="icon-lg"
className="rounded-full"
aria-label="Decrease by 10"
disabled={goal <= 200}
onClick={() => setGoal((value) => value - 10)}
>
<MinusIcon />
</Button>
<div className="flex w-32 flex-col items-center">
<output
aria-live="polite"
className="text-display-lg text-foreground tabular-nums"
>
{goal}
</output>
<span className="text-overline text-muted-foreground">
kcal / day
</span>
</div>
<Button
variant="outline"
size="icon-lg"
className="rounded-full"
aria-label="Increase by 10"
disabled={goal >= 800}
onClick={() => setGoal((value) => value + 10)}
>
<PlusIcon />
</Button>
</div>
<DrawerFooter>
<DrawerClose render={<Button size="lg" />}>Save goal</DrawerClose>
<DrawerClose render={<Button variant="ghost" size="lg" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/drawer.jsonUsage
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>Set daily goal</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Daily move goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
{/* … */}
<DrawerFooter>
<DrawerClose render={<Button size="lg" />}>Save goal</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>The drawer is built on the Base UI Drawer: the panel tracks the pointer while it's dragged, dismisses when the gesture is fast or far enough, and settles on the nearest resting point instead of stopping wherever the finger let go.
Examples
Bottom sheet
The default: a drawer that rises from the bottom and is dismissed by swiping down. showSwipeHandle renders the grab handle that tells people it can be dragged. Center the content with a max-w-* wrapper so it stays readable on tablets — a share sheet is the archetype.
import {
CopyIcon,
DownloadIcon,
LinkIcon,
MailIcon,
MessageCircleIcon,
PrinterIcon,
ShareIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const targets = [
{ label: "Copy link", icon: LinkIcon },
{ label: "Messages", icon: MessageCircleIcon },
{ label: "Mail", icon: MailIcon },
{ label: "Duplicate", icon: CopyIcon },
{ label: "Download", icon: DownloadIcon },
{ label: "Print", icon: PrinterIcon },
]
export default function DrawerShare() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
<ShareIcon data-icon="inline-start" />
Share
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Share “Q3 roadmap”</DrawerTitle>
<DrawerDescription>
Anyone with the link can view.
</DrawerDescription>
</DrawerHeader>
<div className="grid grid-cols-3 gap-2 px-6 py-4">
{targets.map((target) => (
<DrawerClose
key={target.label}
render={
<button
type="button"
className="flex flex-col items-center gap-2 rounded-xl p-3 text-xs text-muted-foreground focus-ring transition-colors duration-fast hover:bg-accent hover:text-foreground"
/>
}
>
<span className="flex size-12 items-center justify-center rounded-full border bg-card text-foreground shadow-raised">
<target.icon className="size-5" />
</span>
{target.label}
</DrawerClose>
))}
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" size="lg" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Snap points
snapPoints lets the drawer rest at several heights. Fractions between 0 and 1 are portions of the viewport height; numbers above 1 are pixels, and strings accept px and rem. The drawer opens at the first point; drag it up to reach the next one.
import { MapPinIcon, StarIcon, XIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const places = [
{ name: "Blue Bottle Coffee", kind: "Café", distance: "120 m", rating: 4.7 },
{ name: "Tartine Bakery", kind: "Bakery", distance: "350 m", rating: 4.8 },
{ name: "Dolores Park", kind: "Park", distance: "600 m", rating: 4.6 },
{ name: "Bi-Rite Market", kind: "Grocery", distance: "650 m", rating: 4.5 },
{ name: "Mission Dolores", kind: "Landmark", distance: "900 m", rating: 4.4 },
{
name: "Foreign Cinema",
kind: "Restaurant",
distance: "1.1 km",
rating: 4.6,
},
{ name: "The Chapel", kind: "Music venue", distance: "1.2 km", rating: 4.7 },
{
name: "Clarion Alley",
kind: "Street art",
distance: "1.3 km",
rating: 4.5,
},
]
export default function DrawerSnapPoints() {
return (
<Drawer showSwipeHandle snapPoints={[0.45, 1]}>
<DrawerTrigger render={<Button variant="outline" />}>
<MapPinIcon data-icon="inline-start" />
Nearby places
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Nearby</DrawerTitle>
<DrawerDescription>
Drag up to see all {places.length} places.
</DrawerDescription>
</DrawerHeader>
{/* At the top, not in a footer: at the first snap point the bottom of
the drawer is off-screen, and a button placed there would be too. */}
<DrawerClose
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Close"
className="absolute top-4 right-4"
/>
}
>
<XIcon />
</DrawerClose>
<ul className="flex flex-col overflow-y-auto px-3 pb-6">
{places.map((place) => (
<li
key={place.name}
className="flex items-center gap-3 rounded-lg px-3 py-3 hover:bg-accent"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-surface text-muted-foreground">
<MapPinIcon className="size-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{place.name}
</span>
<span className="text-xs text-muted-foreground">
{place.kind} · {place.distance}
</span>
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<StarIcon className="size-3 fill-current text-warning-9" />
{place.rating.toLocaleString("en-US", {
minimumFractionDigits: 1,
})}
</span>
</li>
))}
</ul>
</DrawerContent>
</Drawer>
)
}<Drawer showSwipeHandle snapPoints={[0.45, 1]}>Directions
swipeDirection sets both the edge and the dismiss gesture: down (default), up, left or right. Side drawers are 75% wide on phones and 24rem from sm up.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const directions = [
{
direction: "right",
label: "From right",
hint: "Swipe right or press Esc to dismiss it.",
},
{
direction: "left",
label: "From left",
hint: "Swipe left or press Esc to dismiss it.",
},
{
direction: "up",
label: "From top",
hint: "Swipe up or press Esc to dismiss it.",
},
] as const
export default function DrawerDirections() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
{directions.map(({ direction, label, hint }) => (
<Drawer key={direction} swipeDirection={direction} showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
{label}
</DrawerTrigger>
<DrawerContent>
<div className="flex flex-1 flex-col">
<DrawerHeader>
<DrawerTitle>Swipe to dismiss</DrawerTitle>
<DrawerDescription>{hint}</DrawerDescription>
</DrawerHeader>
<div className="mx-6 my-2 flex min-h-24 flex-1 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
Content
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
))}
</div>
)
}Nested
Open a drawer from inside another and the parent steps back into a stack — slightly smaller, slightly dimmed — so people see where they'll return. Swiping the child away brings the parent forward again.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function DrawerNested() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
Checkout
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Order summary</DrawerTitle>
<DrawerDescription>2 items · Ships in 1–2 days</DrawerDescription>
</DrawerHeader>
<dl className="flex flex-col gap-2 px-6 py-4 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">$148.00</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">$6.00</dd>
</div>
<div className="flex justify-between border-t pt-2 font-medium">
<dt>Total</dt>
<dd className="tabular-nums">$154.00</dd>
</div>
</dl>
<DrawerFooter>
{/* A drawer inside a drawer: the parent steps back into a stack. */}
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button size="lg" />}>
Add a promo code
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Promo code</DrawerTitle>
<DrawerDescription>
Codes are case-insensitive.
</DrawerDescription>
</DrawerHeader>
<div className="px-6 py-2">
<Field>
<FieldLabel htmlFor="promo-code">Code</FieldLabel>
<Input id="promo-code" placeholder="SPRING26" />
</Field>
</div>
<DrawerFooter>
<DrawerClose render={<Button size="lg" />}>
Apply
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="ghost" size="lg" />}>
Back to cart
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Responsive
On desktop, a centered Dialog is easier to reach with a pointer; on phones, a bottom drawer is within thumb reach. Share the content and switch the container with useIsMobile.
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useIsMobile } from "@/hooks/use-mobile"
function InviteForm() {
return (
<FieldGroup>
<Field>
<FieldLabel htmlFor="invite-email">Email</FieldLabel>
<Input id="invite-email" type="email" placeholder="grace@company.com" />
</Field>
</FieldGroup>
)
}
/** A dialog on desktop, a bottom drawer below the `md` breakpoint. */
export default function DrawerResponsive() {
const isMobile = useIsMobile()
const title = "Invite teammate"
const description = "They’ll get an email with a link to join Acme."
if (isMobile) {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
{title}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="px-6 py-2">
<InviteForm />
</div>
<DrawerFooter>
<Button size="lg">Send invite</Button>
<DrawerClose render={<Button variant="ghost" size="lg" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
{title}
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<InviteForm />
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>
Cancel
</DialogClose>
<Button>Send invite</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}const isMobile = useIsMobile()
return isMobile ? (
<Drawer showSwipeHandle>{/* … */}</Drawer>
) : (
<Dialog>{/* … */}</Dialog>
)Guidelines
When to use
- For contextual tasks on touch devices: pickers, short forms, share sheets, filters.
- When the content benefits from resting at several heights, like a map with a list of places.
When not to use
- On pointer-first desktop layouts — use a Dialog or a Sheet.
- For destructive confirmations — use an Alert Dialog, which can't be swiped away by accident.
- For long, complex forms — give them a page.
Gestures are a shortcut, never the only way
Everything a gesture does must also be possible with a tap or a key. Keep a visible close or cancel action in the footer, and don't rely on swiping between snap points to reveal essential content.
Thumb reach
Put primary actions at the bottom of the drawer, full width, in the lg size (40px tall). That's where thumbs are, and it clears the 24px minimum target comfortably.
Accessibility
The drawer is a modal dialog: it traps focus, locks page scroll, and is labeled by DrawerTitle and described by DrawerDescription.
| Key | Behavior |
|---|---|
EnterSpace | On the trigger: opens the drawer and moves focus into it. |
Tab | Moves through the drawer's controls. Focus is trapped while it's open. |
ShiftTab | Moves focus backwards. |
Esc | Closes the drawer and returns focus to the trigger. |
- Always include a title. It names the drawer for assistive technology; hide it with
sr-onlyif the design has none. - The swipe handle is decorative and hidden from assistive technology — provide a real close button.
- Motion. The drawer follows the pointer 1:1 while dragging and settles over 480ms (
duration-slower). With reduced motion, the settle animation is removed; dragging still tracks the finger, because that motion is caused by the person, not by the interface.
API reference
Drawer
The root. Doesn't render an element. Accepts every prop of Base UI Drawer.Root.
DrawerContent
Renders the portal, scrim, viewport and panel; the scrim is left out unless modal is true. Accepts every prop of Base UI Drawer.Popup, including initialFocus and finalFocus.
The content area scrolls when it is taller than the drawer may grow: vertical drawers are capped at 100dvh - 6rem, which leaves little room on a phone in landscape. The header and footer scroll with it. To keep them in place, wrap the long part in its own element with overflow-y-auto, as the snap points example does with its list.
DrawerPortal, DrawerOverlay (the scrim) and DrawerSwipeHandle (the grab handle) are the pieces DrawerContent assembles. They are exported for custom compositions; you don't need them otherwise.
DrawerHeader, DrawerFooter
DrawerHeader holds the title and description — centered on vertical drawers below md, left-aligned above. DrawerFooter stacks actions full width at the bottom.
DrawerTitle, DrawerDescription
DrawerTitle renders an <h2> and names the drawer. DrawerDescription renders a <p> and describes it.
DrawerTrigger, DrawerClose
Open and close the drawer. Both render a <button>; use render to render a prfct Button.