Drawer
A drawer component for React.
"use client"
import * as React from "react"
import { toast } from "sonner"
import { useIsMobile } from "@/hooks/use-mobile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Toaster } from "@/components/ui/sonner"
const templates = [
{
value: "modern",
id: "template-modern",
label: "Modern",
description: "Clean two-column layout · Recruiter favorite",
badge: "Most popular",
},
{
value: "timeline",
id: "template-timeline",
label: "Timeline",
description: "Great for showing career progression",
},
{
value: "executive",
id: "template-executive",
label: "Executive",
description: "Formal single-column layout for senior roles",
},
{
value: "minimal",
id: "template-minimal",
label: "Minimal",
description: "Maximum content, minimal styling",
},
{
value: "creative",
id: "template-creative",
label: "Creative",
description: "Bold accents for design and marketing roles",
},
]
export default function DrawerDemo() {
const [open, setOpen] = React.useState(false)
const [template, setTemplate] = React.useState("modern")
const isMobile = useIsMobile()
function handleConfirm() {
const selected = templates.find((item) => item.value === template)
setOpen(false)
toast("Template applied", {
description: selected?.label,
})
}
return (
<>
<Toaster />
<Drawer
open={open}
onOpenChange={setOpen}
showSwipeHandle={isMobile}
swipeDirection={isMobile ? "down" : "right"}
>
<DrawerTrigger render={<Button variant="secondary" />}>
Choose template
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Pick a resume template</DrawerTitle>
<DrawerDescription>
Your content stays the same — only the design changes.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">
<RadioGroup
value={template}
onValueChange={(value) => setTemplate(value as string)}
className="gap-2"
>
{templates.map((item) => (
<FieldLabel key={item.value} htmlFor={item.id}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle className="flex items-center gap-2">
{item.label}
{item.badge ? (
<Badge variant="secondary">{item.badge}</Badge>
) : null}
</FieldTitle>
<FieldDescription>{item.description}</FieldDescription>
</FieldContent>
<RadioGroupItem value={item.value} id={item.id} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<DrawerFooter>
<Button onClick={handleConfirm}>Apply template</Button>
<DrawerClose render={<Button variant="outline" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}The drawer component uses Base UI.
Installation
pnpm dlx shadcn@latest add @kickresume/drawerAdd the following to your global styles. On iOS Safari, the drawer overlay is absolutely positioned and requires a positioned body to cover the viewport after the page is scrolled. See the Base UI docs for details.
body {
position: relative;
}Usage
import { Drawer, DrawerPortal, DrawerOverlay, DrawerSwipeHandle, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"<>
<Toaster />
<Drawer
open={open}
onOpenChange={setOpen}
showSwipeHandle={isMobile}
swipeDirection={isMobile ? "down" : "right"}
>
<DrawerTrigger render={<Button variant="secondary" />}>
Choose template
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Pick a resume template</DrawerTitle>
<DrawerDescription>
Your content stays the same — only the design changes.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">
<RadioGroup
value={template}
onValueChange={(value) => setTemplate(value as string)}
className="gap-2"
>
{templates.map((item) => (
<FieldLabel key={item.value} htmlFor={item.id}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle className="flex items-center gap-2">
{item.label}
{item.badge ? (
<Badge variant="secondary">{item.badge}</Badge>
) : null}
</FieldTitle>
<FieldDescription>{item.description}</FieldDescription>
</FieldContent>
<RadioGroupItem value={item.value} id={item.id} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<DrawerFooter>
<Button onClick={handleConfirm}>Apply template</Button>
<DrawerClose render={<Button variant="outline" />}>
Cancel
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>Composition
Use the following composition to build a Drawer:
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooterDrawerContent composes the portal, overlay, viewport, and popup from Base UI. For lower-level control, DrawerPortal, DrawerOverlay, and DrawerSwipeHandle are also exported.
Custom Sizes
A vertical drawer sizes itself to its content and is capped at calc(100dvh - 6rem) by default. A side drawer spans 75% of the viewport width, or 24rem on larger screens.
To customize the height of a vertical drawer, use the h-* and max-h-* utilities on DrawerContent.
<DrawerContent className="h-[50vh]">To customize the width of a side drawer, use the w-* and max-w-* utilities on DrawerContent.
<DrawerContent className="w-96">When the same component renders in multiple directions, scope an override to one axis using the data-[swipe-axis=*] variants.
<DrawerContent className="data-[swipe-axis=y]:max-h-[50vh] data-[swipe-axis=x]:w-96">To make a region of the drawer scrollable, make the scroll container a flex item. Avoid h-full, which does not resolve inside a content-sized drawer.
<DrawerContent>
<DrawerHeader>...</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">{/* Scrollable content */}</div>
<DrawerFooter>...</DrawerFooter>
</DrawerContent>Styling
The drawer exposes CSS variables for style-level customization. Set the sizing variables on DrawerContent. Set the overlay variable on [data-slot=drawer-overlay] in your CSS.
| Variable | Default | Description |
|---|---|---|
--drawer-inset | 0px | Floats the drawer from the viewport edges. |
--drawer-bleed-background | var(--color-popover) | Fills the gap behind the drawer on swipe overshoot. |
--drawer-overlay-min-opacity | 0 | Minimum overlay opacity. Defaults to 0.5 when snap points are active. |
The drawer also sets data attributes you can target with variants such as data-[swipe-direction=down]: on DrawerContent, or group-data-[swipe-axis=y]/drawer-popup: on its descendants.
| Attribute | Values | Set when |
|---|---|---|
data-swipe-direction | up, right, down, left | Always. |
data-swipe-axis | x, y | Always. |
data-snap-points | Present | The drawer has snap points. |
data-expanded | Present | The drawer is at the full snap point. |
data-swiping | Present | A swipe is in progress. |
data-nested-drawer-open | Present | A nested drawer is open on top. |
Examples
Position
Use the swipeDirection prop to set the side of the drawer.
Available options are up, right, down, and left.
"use client"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const sides = [
{ direction: "up", label: "Top" },
{ direction: "right", label: "Right" },
{ direction: "down", label: "Bottom" },
{ direction: "left", label: "Left" },
] as const
export default function DrawerSides() {
return (
<div className="flex flex-wrap items-center gap-2">
{sides.map((side) => (
<Drawer key={side.direction} swipeDirection={side.direction}>
<DrawerTrigger render={<Button variant="outline" />}>
{side.label}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{side.label} drawer</DrawerTitle>
<DrawerDescription>
Slides in from the {side.label.toLowerCase()}.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-40 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="ghost" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
)
}Swipe Handle
Use showSwipeHandle on Drawer to render a swipe handle.
"use client"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerSwipeHandle() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="secondary" />}>
Open drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Resume filters</DrawerTitle>
<DrawerDescription>Drag the handle to dismiss.</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Nested
Open drawers from inside another drawer. Parent drawers stay mounted and stack behind the frontmost drawer.
"use client"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerNested() {
const isMobile = useIsMobile()
const swipeDirection = isMobile ? "down" : "right"
return (
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
<DrawerTrigger render={<Button variant="secondary" />}>
Open drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>
Open another drawer from the same direction.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
<DrawerTrigger render={<Button variant="outline" />}>
Open nested drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Nested drawer</DrawerTitle>
<DrawerDescription>
The parent drawer stays mounted behind this one.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<Drawer
showSwipeHandle={isMobile}
swipeDirection={swipeDirection}
>
<DrawerTrigger render={<Button variant="outline" />}>
Open third drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Third drawer</DrawerTitle>
<DrawerDescription>
Two drawers are stacked behind this one.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>
Close
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Non Modal
Set modal={false} to allow interaction with the rest of the page while the drawer is open. Combine with disablePointerDismissal to prevent the drawer from closing on outside presses. Use modal="trap-focus" to keep focus inside the drawer while leaving scroll and pointer interaction unrestricted.
"use client"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export default function DrawerNonModal() {
return (
<Drawer modal={false} disablePointerDismissal swipeDirection="right">
<DrawerTrigger render={<Button variant="outline" />}>
Non modal
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Non modal drawer</DrawerTitle>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Snap Points
Use snapPoints to snap a drawer to preset heights. Numbers between 0 and 1 represent fractions of the viewport. Numbers greater than 1 are treated as pixel values. String values support px and rem units. Snap points apply to vertical drawers.
Track the active snap point with the controlled snapPoint and onSnapPointChange props. At the full snap point, the drawer gets a data-expanded attribute you can style with the data-expanded: variant.
"use client"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const SNAP_POINTS = ["31rem", 1]
export default function DrawerSnapPoints() {
return (
<Drawer snapPoints={SNAP_POINTS} showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
Open snap drawer
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Snap points</DrawerTitle>
<DrawerDescription>
Drag the drawer to snap between a compact peek and a near
full-height view.
</DrawerDescription>
</DrawerHeader>
<div className="flex-1 p-4">
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}Responsive
You can combine the Dialog and Drawer components to create a responsive dialog. This renders a Dialog component on desktop and a Drawer on mobile.
"use client"
import * as React from "react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function DrawerDialog() {
const [open, setOpen] = React.useState(false)
const isMobile = useIsMobile()
if (!isMobile) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="outline" />}>
Edit profile
</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 render={<Button variant="outline" />}>
Edit profile
</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="p-4" />
</DrawerContent>
</Drawer>
)
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
return (
<form className={cn("grid items-start gap-6", className)}>
<div className="grid gap-3">
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" defaultValue="ada@kickresume.com" />
</div>
<div className="grid gap-3">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@ada" />
</div>
<Button type="submit">Save changes</Button>
</form>
)
}API Reference
See the Base UI documentation for more information.