Components
A panel that slides in from an edge of the screen and can be dragged to dismiss. Built on top of Vaul.
Playground
mt-auto, and DrawerClose closes from anywhere via asChild. Bottom drawers render a drag handle automatically.import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@workspace/ui/components/drawer"
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>This action cannot be undone.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>direction prop lives on the Drawer root — it drives the drag gesture, not just the panel position: top, right, bottom (the default) or left. Vertical directions span the full width, size to their content and cap at max-h-[80vh]; horizontal directions span the full height and cap at sm:max-w-sm. Only the bottom drawer shows the drag handle.<Drawer direction="top">…</Drawer> <Drawer direction="right">…</Drawer> <Drawer direction="bottom">…</Drawer> <Drawer direction="left">…</Drawer>
const [goal, setGoal] = React.useState(350)
const adjustGoal = (adjustment: number) =>
setGoal((current) => Math.max(200, Math.min(400, current + adjustment)))
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open drawer</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="flex items-center justify-center gap-4 p-4 pb-0">
<Button
variant="outline"
size="icon"
className="size-8 shrink-0 rounded-full"
onClick={() => adjustGoal(-10)}
disabled={goal <= 200}
>
<MinusIcon />
<span className="sr-only">Decrease</span>
</Button>
<div className="text-center">
<div className="text-7xl font-bold tracking-tighter tabular-nums">
{goal}
</div>
<div className="text-xs text-muted-foreground uppercase">
Calories/day
</div>
</div>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0 rounded-full"
onClick={() => adjustGoal(10)}
disabled={goal >= 400}
>
<PlusIcon />
<span className="sr-only">Increase</span>
</Button>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>overflow-y-auto and it scrolls independently while the header and footer stay pinned. Match the header padding with px-4 on the scrolling block.<Drawer direction="right">
<DrawerTrigger asChild>
<Button variant="outline">Read the changelog</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Changelog</DrawerTitle>
<DrawerDescription>Everything new in the design system.</DrawerDescription>
</DrawerHeader>
<div className="space-y-4 overflow-y-auto px-4 text-muted-foreground">
{entries.map((entry, index) => (
<p key={index}>{entry}</p>
))}
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>md breakpoint via the useIsMobile hook. The preview follows the current viewport — narrow the window to get the drawer.import { useIsMobile } from "@workspace/ui/hooks/use-mobile"
const isMobile = useIsMobile()
if (isMobile) {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DrawerTrigger>
<DrawerContent>{/* … */}</DrawerContent>
</Drawer>
)
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">{/* … */}</DialogContent>
</Dialog>
)start-0, border-e, rounded-e-xl), and the header aligns with md:text-start — so passing dir="rtl" mirrors the panel chrome without extra classes. The direction prop stays physical — choose it for the layout, not the language.<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">فتح الدرج</Button>
</DrawerTrigger>
<DrawerContent dir="rtl">
<DrawerHeader>
<DrawerTitle>تعديل الملف الشخصي</DrawerTitle>
<DrawerDescription>
قم بإجراء تغييرات على ملفك الشخصي هنا. انقر على حفظ عند الانتهاء.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button>حفظ</Button>
<DrawerClose asChild>
<Button variant="outline">إلغاء</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>aria-labelledby and is required — if the design hides it, keep it in the tree with sr-only. DrawerDescription wires up aria-describedby. The drag handle is decorative and pointer-only, so always keep a visible DrawerClose (or rely on Escape) as the accessible way out.<DrawerContent>
<DrawerHeader>
<DrawerTitle className="sr-only">Filters</DrawerTitle>
<DrawerDescription className="sr-only">
Narrow down the product list
</DrawerDescription>
</DrawerHeader>
{/* visually title-less content */}
</DrawerContent>asChild to keep your own markup), the portal and overlay handled automatically by DrawerContent, plus the DrawerHeader / DrawerTitle / DrawerDescription / DrawerFooter layout parts. See the Vaul documentation for the full primitive API.| Component | Prop | Default |
|---|---|---|
| Drawer | open?: boolean, onOpenChange?: (open: boolean) => void | — |
| Drawer | direction?: "top" | "right" | "bottom" | "left" | "bottom" |
| Drawer | dismissible?: boolean | true |
| Drawer | modal?: boolean | true |
| Drawer | snapPoints?: (number | string)[] | — |
| DrawerTrigger / DrawerClose | asChild?: boolean | false |
<Drawer data-slot="drawer" /> <DrawerTrigger data-slot="drawer-trigger" /> <DrawerContent data-slot="drawer-content" data-vaul-drawer-direction /> <DrawerOverlay data-slot="drawer-overlay" data-open data-closed /> <DrawerHeader data-slot="drawer-header" /> <DrawerTitle data-slot="drawer-title" /> <DrawerDescription data-slot="drawer-description" /> <DrawerFooter data-slot="drawer-footer" /> <DrawerClose data-slot="drawer-close" />
Tokens used
12 design tokens consumed by the Drawer component.
--popover
Popover
--popover-foreground
Popover Foreground
--foreground
Foreground
--muted-foreground
Muted Foreground
--muted
Muted
--border
Border
--font-heading
GT Alpina Condensed
--text-sm
Text Small (14px)
--text-lg
Text Large (18px)
--font-weight-medium
Font weight Medium (500)
--radius-xl
Radius 1.4× (14px)
--spacing
Spacing unit (4px)