Drawer
Bottom sheet drawer built on Vaul. Supports drag-to-dismiss and scale background.
Examples
Responsive Dialog
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.
import { Button } from "@codefast/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@codefast/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@codefast/ui/drawer";
import { useMediaQuery } from "@codefast/ui/hooks/use-media-query";
import { Input } from "@codefast/ui/input";
import { Label } from "@codefast/ui/label";
import { cn } from "@codefast/ui/lib/utils";
import * as React from "react";
export function DrawerDialogDemo() {
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-106.25">
<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-start">
<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>
);
}
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="hello@codefastlabs.com" />
</div>
<div className="grid gap-3">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@codefast" />
</div>
<Button type="submit">Save changes</Button>
</form>
);
}
RTL
Right-to-left layout support for languages such as Arabic and Hebrew.
Translations are AI-generated for demonstration and may be imperfect.
import { Button } from "@codefast/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@codefast/ui/drawer";
import { Minus, Plus } from "lucide-react";
import * as React from "react";
import { Bar, BarChart, ResponsiveContainer, XAxis } from "recharts";
import type { Translations } from "#/features/components-catalog/components/detail/language";
import { useTranslation } from "#/features/components-catalog/components/detail/language-context";
const data = [
{
goal: 400,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 239,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 349,
},
];
const translations: Translations = {
en: {
dir: "ltr",
locale: "en-US",
values: {
trigger: "Open Drawer",
title: "Move Goal",
description: "Set your daily activity goal.",
caloriesPerDay: "Calories/day",
decrease: "Decrease",
increase: "Increase",
submit: "Submit",
cancel: "Cancel",
},
},
ar: {
dir: "rtl",
locale: "ar-EG",
values: {
trigger: "فتح الدرج",
title: "نقل الهدف",
description: "حدد هدف نشاطك اليومي.",
caloriesPerDay: "سعرات حرارية/يوم",
decrease: "تقليل",
increase: "زيادة",
submit: "إرسال",
cancel: "إلغاء",
},
},
he: {
dir: "rtl",
locale: "he-IL",
values: {
trigger: "פתח מגירה",
title: "הזז מטרה",
description: "הגדר את יעד הפעילות היומי שלך.",
caloriesPerDay: "קלוריות/יום",
decrease: "הקטן",
increase: "הגדל",
submit: "שלח",
cancel: "בטל",
},
},
};
export function DrawerRtl() {
const { dir, locale, language, t } = useTranslation(translations, "ar");
const [goal, setGoal] = React.useState(350);
function onClick(adjustment: number) {
setGoal(Math.max(200, Math.min(400, goal + adjustment)));
}
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{t.trigger}</Button>
</DrawerTrigger>
<DrawerContent dir={dir} data-lang={dir === "rtl" ? language : undefined}>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>{t.title}</DrawerTitle>
<DrawerDescription>{t.description}</DrawerDescription>
</DrawerHeader>
<div className="p-4 pb-0">
<div className="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={() => onClick(-10)}
disabled={goal <= 200}
>
<Minus />
<span className="sr-only">{t.decrease}</span>
</Button>
<div className="flex-1 text-center">
<div className="text-7xl font-bold tracking-tighter">{goal.toLocaleString(locale)}</div>
<div className="text-[0.70rem] text-muted-foreground uppercase">{t.caloriesPerDay}</div>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={() => onClick(10)}
disabled={goal >= 400}
>
<Plus />
<span className="sr-only">{t.increase}</span>
</Button>
</div>
<div className="mt-3 h-30">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<XAxis
dataKey="goal"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.toLocaleString(locale)}
reversed={dir === "rtl"}
/>
<Bar
dataKey="goal"
style={
{
fill: "var(--chart-2)",
} as React.CSSProperties
}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<DrawerFooter>
<Button>{t.submit}</Button>
<DrawerClose asChild>
<Button variant="outline">{t.cancel}</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}
Scrollable Content
Keep actions visible while the content scrolls.
import { Button } from "@codefast/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@codefast/ui/drawer";
export function DrawerScrollableContent() {
return (
<Drawer direction="right">
<DrawerTrigger asChild>
<Button variant="outline">Scrollable Content</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="no-scrollbar overflow-y-auto px-4">
{Array.from({ length: 10 }).map((_, index) => (
<p key={index} className="style-lyra:mb-2 style-lyra:leading-relaxed mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</p>
))}
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
Sides
Use the direction prop to set the side of the drawer. Available options are top, right, bottom, and left.
import { Button } from "@codefast/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@codefast/ui/drawer";
const DRAWER_SIDES = ["top", "right", "bottom", "left"] as const;
export function DrawerWithSides() {
return (
<div className="flex flex-wrap gap-2">
{DRAWER_SIDES.map((side) => (
<Drawer key={side} direction={side}>
<DrawerTrigger asChild>
<Button variant="outline" className="capitalize">
{side}
</Button>
</DrawerTrigger>
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[50vh] data-[vaul-drawer-direction=top]:max-h-[50vh]">
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div className="no-scrollbar overflow-y-auto px-4">
{Array.from({ length: 10 }).map((_, index) => (
<p key={index} className="style-lyra:mb-2 style-lyra:leading-relaxed mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum
dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
officia deserunt mollit anim id est laborum.
</p>
))}
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
);
}
Usage
The minimal import and composition — see Examples below for styled, real-world variants.
import { Button } from "@codefast/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@codefast/ui/drawer";
export function DrawerUsage() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>Make changes to your profile here.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button>Save changes</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
Anatomy
How the parts nest — every slot the component exposes, in composition order.
Features
- Built on Vaul — supports drag-to-dismiss gestures on touch, not just a click-to-close button.
- shouldScaleBackground (default true) shrinks and scales the page behind the drawer as it opens, like an iOS-style bottom sheet.
- Slides from any of four directions via the direction prop (top/right/bottom/left) — not just the default bottom.
- Pairs naturally with Dialog for a responsive pattern: Dialog on desktop, Drawer on mobile, switched by a media query.
API reference
Props for each part of the component. All native element props are also forwarded.
Drawer
Root. A bottom sheet built on Vaul with gesture support.
openbooleanThe controlled open state.
defaultOpenbooleanThe open state when initially rendered (uncontrolled).
Default
falseonOpenChange(open: boolean) => voidCalled when the open state changes.
shouldScaleBackgroundbooleanScale the page behind the drawer as it opens.
Default
true
DrawerContent
The sliding panel itself.
childrenReactNodeThe panel content.
DrawerClose
A button that dismisses the drawer.
childrenReactNodeThe close button's content.
Accessibility
Built to be keyboard-navigable and screen-reader friendly out of the box.
| Key | Function |
|---|---|
| Tab | Cycles focus within the drawer (trapped). |
| Esc | Closes the drawer. |
- Supports drag-to-dismiss on touch, with a keyboard path to close too.
- Always provide a DrawerTitle for an accessible name.
- On desktop, a Sheet or Dialog is often a better fit than a bottom drawer.
Guidelines
Conventions that keep usage consistent across an app.
Do
- Use on mobile for quick, focused tasks reachable by thumb.
- Keep content short so the drawer doesn’t fill the screen.
Don’t
- Don’t put long, multi-step flows in a drawer.
- Don’t use a drawer where a Dialog reads better on desktop.
Explore further
Ready to integrate?
Follow the Getting Started guide to install @codefast/ui, or browse the full component gallery.