Westy Design Systemv0.2.7
OverviewComponentsExamplesStorybook

Foundations

  • Typography
  • Colors
  • Spacing
  • Radius
  • Shadows
  • Icons

Components

  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Data Table
  • Date Picker
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Menubar
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Questionnaire
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sonner
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toggle
  • Toggle Group
  • Tooltip

Chat

  • Attachment
  • Bubble
  • Marker
  • Message
  • Message Scroller

Components

Drawer

A panel that slides in from an edge of the screen and can be dragged to dismiss. Built on top of Vaul.

Playground

—direction
—DrawerTitle children
—DrawerDescription children
—dismissible
—modal
Usage
Drawer wraps a DrawerTrigger and a DrawerContent that slides in from an edge of the screen. Inside the content, DrawerHeader groups DrawerTitle and DrawerDescription, DrawerFooter sticks to the end via 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
The 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>
Interactive content
The content is a regular React tree — local state works while the drawer is open. This is the classic move-goal demo: the buttons adjust a value clamped between 200 and 400, and dragging the handle past the threshold still dismisses the panel.
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>
Scrollable content
DrawerContent is a flex column, so give the long section 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>
Responsive dialog
Combine Drawer and Dialog for edit-in-place flows: a Dialog on desktop, a bottom Drawer under the 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>
)
RTL
DrawerContent positions side panels and draws their edge border with logical properties (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>
Accessibility
Vaul builds on the Radix Dialog primitive: focus is trapped inside the panel, Escape and overlay clicks close it, and focus returns to the trigger on close. DrawerTitle labels the drawer via 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>
API Reference
Ten parts wrap the Vaul primitive with Westy styling: Drawer as the stateful root (every Vaul prop passes through), DrawerTrigger and DrawerClose as behavior wrappers (use 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.
ComponentPropDefault
Draweropen?: boolean, onOpenChange?: (open: boolean) => void—
Drawerdirection?: "top" | "right" | "bottom" | "left""bottom"
Drawerdismissible?: booleantrue
Drawermodal?: booleantrue
DrawersnapPoints?: (number | string)[]—
DrawerTrigger / DrawerCloseasChild?: booleanfalse
<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

    bg-popover
  • --popover-foreground

    Popover Foreground

    text-popover-foreground
  • --foreground

    Foreground

    text-foreground
  • --muted-foreground

    Muted Foreground

    text-muted-foreground
  • --muted

    Muted

    bg-muted
  • --border

    Border

    border-tborder-eborder-bborder-s
  • --font-heading

    GT Alpina Condensed

    font-heading
  • --text-sm

    Text Small (14px)

    text-sm
  • --text-lg

    Text Large (18px)

    text-lg
  • --font-weight-medium

    Font weight Medium (500)

    font-medium
  • --radius-xl

    Radius 1.4× (14px)

    rounded-t-xlrounded-e-xlrounded-b-xlrounded-s-xl
  • --spacing

    Spacing unit (4px)

    p-4gap-2gap-1.5gap-0.5mt-24mt-4h-1.5