A drawer component for React.

Client JS

Pick a delivery time

We'll prepare your order as soon as possible.

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"

const deliveryTimes = [
  {
    value: "asap",
    id: "delivery-asap",
    label: "Standard delivery",
    description: "25–35 min · Driver assigned now",
    badge: "Fastest",
  },
  {
    value: "5-00",
    id: "delivery-5-00",
    label: "5:00 PM – 5:15 PM",
    description: "Prep starts at 4:45 PM",
  },
  {
    value: "5-30",
    id: "delivery-5-30",
    label: "5:30 PM – 5:45 PM",
    description: "Good if you're heading home",
  },
  {
    value: "6-00",
    id: "delivery-6-00",
    label: "6:00 PM – 6:15 PM",
    description: "Most popular · High demand",
  },
  {
    value: "6-30",
    id: "delivery-6-30",
    label: "6:30 PM – 6:45 PM",
    description: "Last slot before kitchen closes",
  },
]

export function DrawerDemo() {
  return (
    <Drawer swipeDirection="right">
      <DrawerTrigger render={<Button variant="secondary" />}>
        Open Drawer
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Pick a delivery time</DrawerTitle>
          <DrawerDescription>
            We&apos;ll prepare your order as soon as possible.
          </DrawerDescription>
        </DrawerHeader>
        <div class="flex-1 scroll-fade overflow-y-auto p-4">
          <RadioGroup defaultValue="asap" class="gap-2">
            {deliveryTimes.map((time) => (
              <FieldLabel key={time.value} for={time.id}>
                <Field orientation="horizontal">
                  <FieldContent>
                    <FieldTitle class="flex items-center gap-2">
                      {time.label}
                      {time.badge ? (
                        <Badge variant="secondary">{time.badge}</Badge>
                      ) : null}
                    </FieldTitle>
                    <FieldDescription>{time.description}</FieldDescription>
                  </FieldContent>
                  <RadioGroupItem value={time.value} id={time.id} />
                </Field>
              </FieldLabel>
            ))}
          </RadioGroup>
        </div>
        <DrawerFooter>
          <DrawerClose
            render={
              <Button
                class="h-[34px]"
                data-toast-trigger=""
                data-toast-title="Delivery time confirmed"
              />
            }
          >
            Confirm Delivery Time
          </DrawerClose>
          <DrawerClose render={<Button variant="outline" />}>
            Cancel
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}

Installation

pnpm dlx shadcnui-hono-jsx@latest add drawer

This component uses a small client script. add installs it into public/shadcn/; serve that directory at /shadcn/ and load the script on pages that use the component (see Client Scripts):

<script type="module" src="/shadcn/drawer.js"></script>

Usage

import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer>
  <DrawerTrigger render={<Button variant="outline" />}>Open</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Are you absolutely sure?</DrawerTitle>
      <DrawerDescription>This action cannot be undone.</DrawerDescription>
    </DrawerHeader>
    <div class="p-4">{/* Content here */}</div>
    <DrawerFooter>
      <Button>Submit</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
    └── DrawerFooter

DrawerContent 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 class="h-[50vh]">

To customize the width of a side drawer, use the w-* and max-w-* utilities on DrawerContent.

<DrawerContent class="w-96">

When the same component renders in multiple directions, scope an override to one axis using the data-[swipe-axis=*] variants.

<DrawerContent class="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 class="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.

VariableDefaultDescription
--drawer-inset0pxFloats the drawer from the viewport edges.
--drawer-bleed-backgroundvar(--color-popover)Fills the gap behind the drawer on swipe overshoot.
--drawer-overlay-min-opacity0Minimum 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.

AttributeValuesSet when
data-swipe-directionup, right, down, leftAlways.
data-swipe-axisx, yAlways.
data-snap-pointsPresentThe drawer has snap points.
data-expandedPresentThe drawer is at the full snap point.
data-swipingPresentA swipe is in progress.
data-nested-drawer-openPresentA nested drawer is open on top.

Position

Use the swipeDirection prop to set the side of the drawer.

Available options are up, right, down, and left.

Move Goal

Set your daily activity goal.

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export function DrawerWithSides() {
  return (
    <Drawer swipeDirection="left">
      <DrawerTrigger render={<Button variant="secondary" />}>
        Open Left Drawer
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Move Goal</DrawerTitle>
          <DrawerDescription>Set your daily activity goal.</DrawerDescription>
        </DrawerHeader>
        <div class="flex-1 p-4">
          <div class="size-full rounded-2xl bg-muted" />
        </div>
        <DrawerFooter>
          <DrawerClose render={<Button />}>Close</DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}

Swipe Handle

Use showSwipeHandle on Drawer to render a swipe handle.

Drawer

Drawer with a swipe handle.

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export function DrawerSwipeHandle() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="secondary" />}>
        Open Drawer
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Drawer</DrawerTitle>
          <DrawerDescription>Drawer with a swipe handle.</DrawerDescription>
        </DrawerHeader>
        <div class="flex-1 p-4">
          <div class="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.

Drawer

Open another drawer from the same direction.

Nested Drawer

The parent drawer stays mounted behind this one.

Third Drawer

Two drawers are stacked behind this one.

Fourth Drawer

This is the frontmost drawer in the stack.

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export function DrawerNested() {
  const swipeDirection = "right"

  return (
    <Drawer 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 class="flex-1 p-4">
          <div class="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 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 class="flex-1 p-4">
                <div class="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 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 class="flex-1 p-4">
                      <div class="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 swipeDirection={swipeDirection}>
                        <DrawerTrigger render={<Button variant="outline" />}>
                          Open Fourth Drawer
                        </DrawerTrigger>
                        <DrawerContent>
                          <DrawerHeader>
                            <DrawerTitle>Fourth Drawer</DrawerTitle>
                            <DrawerDescription>
                              This is the frontmost drawer in the stack.
                            </DrawerDescription>
                          </DrawerHeader>
                          <div class="flex-1 p-4">
                            <div class="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>
          <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.

Non Modal Drawer

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export function DrawerNonModal() {
  return (
    <Drawer swipeDirection="right">
      <DrawerTrigger render={<Button variant="outline" />}>
        Non Modal
      </DrawerTrigger>
      <DrawerContent closedby="closerequest">
        <DrawerHeader>
          <DrawerTitle>Non Modal Drawer</DrawerTitle>
        </DrawerHeader>
        <div class="flex-1 p-4">
          <div class="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.

Snap points

Drag the drawer to snap between a compact peek and a near full-height view.

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 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 class="flex-1 p-4">
          <div class="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.

Edit profile

Make changes to your profile here. Click save when you're done.

import { cn } from "cn"
import type { JSX } from "hono/jsx"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function DrawerDialogDemo() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        Edit Profile
      </DialogTrigger>
      <DialogContent class="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Make changes to your profile here. Click save when you&apos;re done.
          </DialogDescription>
        </DialogHeader>
        <ProfileForm />
      </DialogContent>
    </Dialog>
  )
}

function ProfileForm({ class: className }: ComponentProps<"form">) {
  return (
    <form class={cn("grid items-start gap-6", className)}>
      <div class="grid gap-3">
        <Label for="email">Email</Label>
        <Input type="email" id="email" value="shadcn@example.com" />
      </div>
      <div class="grid gap-3">
        <Label for="username">Username</Label>
        <Input id="username" value="@shadcn" />
      </div>
      <Button type="submit">Save changes</Button>
    </form>
  )
}
type ComponentProps<T extends keyof JSX.IntrinsicElements | "svg"> =
  (T extends keyof JSX.IntrinsicElements
    ? JSX.IntrinsicElements[T]
    : Record<string, unknown>) & { class?: string | undefined }

Migrating from Vaul

The base drawer now uses Base UI instead of Vaul. If you installed the previous base drawer, update your usage to the Base UI API.

Update the dependency.

- npm install vaul
+ npm install @base-ui/react

Replace direction with swipeDirection.

Use down instead of bottom, and up instead of top. left and right stay the same.

- <Drawer direction="bottom">
+ <Drawer swipeDirection="down">

Replace asChild with render.

For DrawerTrigger, pass the trigger element to the render prop.

- <DrawerTrigger asChild>
-   <Button variant="outline">Open</Button>
- </DrawerTrigger>
+ <DrawerTrigger render={<Button variant="outline" />}>
+   Open
+ </DrawerTrigger>

For DrawerClose, pass the close element to the render prop.

- <DrawerClose asChild>
-   <Button variant="outline">Cancel</Button>
- </DrawerClose>
+ <DrawerClose render={<Button variant="outline" />}>
+   Cancel
+ </DrawerClose>

Update snap point props.

If you use snap points, rename the controlled snap point props and the sequential snap point prop.

  <Drawer
    snapPoints={[0.25, 0.5, 1]}
-   activeSnapPoint={snapPoint}
-   setActiveSnapPoint={setSnapPoint}
-   snapToSequentialPoint
+   snapPoint={snapPoint}
+   onSnapPointChange={setSnapPoint}
+   snapToSequentialPoints
  >

Update animation and focus props.

- <Drawer onAnimationEnd={(open) => setDone(open)}>
+ <Drawer onOpenChangeComplete={(open) => setDone(open)}>
- <DrawerContent onOpenAutoFocus={(event) => event.preventDefault()}>
+ <DrawerContent initialFocus={false}>

Review Vaul-only props.

Vaul props like handleOnly, repositionInputs, and shouldScaleBackground do not have one-to-one replacements in the base drawer API. Use Base UI props such as disablePointerDismissal, modal, snapPoints, or controlled open state for the behavior you need.

- <Drawer handleOnly repositionInputs={false} shouldScaleBackground>
+ <Drawer>
- <Drawer dismissible={false}>
+ <Drawer disablePointerDismissal>

Update custom data attribute selectors.

Replace Vaul's data-vaul-drawer-direction selectors with Base UI's data-swipe-direction selectors.

- <DrawerContent class="data-[vaul-drawer-direction=bottom]:max-h-[50vh]">
+ <DrawerContent class="data-[swipe-direction=down]:max-h-[50vh]">

Base UI also exposes attributes like data-swiping, data-starting-style, and data-ending-style for swipe and transition states. Descendants inside DrawerContent can use group-data-[swipe-axis=x]/drawer-popup and group-data-[swipe-axis=y]/drawer-popup for axis-specific styling.

Notes

  • Built on the native <dialog> with Invoker Commands (command/commandfor), like Dialog: opening, closing, Escape and outside clicks need no JavaScript (Baseline 2025 browsers). The overlay is the dialog's ::backdrop (the overlay component renders nothing), and the viewport renders nothing.
  • Swiping to close needs the client script /shadcn/drawer.js (<script type="module" src="/shadcn/drawer.js">). Snap points, nested drawer stacking, swipe areas and modal={false} are not supported (the drawer always opens as a modal dialog). Controlled state (open, defaultOpen, onOpenChange) is not supported.
  • Accepts class instead of className.
  • Upstream documentation: shadcn/ui Drawer. See also Differences from shadcn/ui.

API Reference

See the Base UI documentation for the full API reference.