codefast/ui

Command Palette

Search for a command to run...

Source
Overlay

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.

82 lines
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&apos;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&apos;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.

185 lines
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.

44 lines
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.

52 lines
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.

33 lines
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.

Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooter
└── DrawerClose

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.

openboolean

The controlled open state.

defaultOpenboolean

The open state when initially rendered (uncontrolled).

Defaultfalse

onOpenChange(open: boolean) => void

Called when the open state changes.

shouldScaleBackgroundboolean

Scale the page behind the drawer as it opens.

Defaulttrue

DrawerContent

The sliding panel itself.

childrenReactNode

The panel content.

DrawerClose

A button that dismisses the drawer.

childrenReactNode

The close button's content.

Accessibility

Built to be keyboard-navigable and screen-reader friendly out of the box.

KeyFunction
TabCycles focus within the drawer (trapped).
EscCloses 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.