Free shadcn/ui drawer component for React — the drag-to-dismiss panel built on vaul. Snap points, nested drawers, four directions, and the responsive Dialog-on-desktop / Drawer-on-mobile pattern. The examples official shadcn never shipped.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

vaul
utils

Props

direction = "bottom"
"top" | "right" | "bottom" | "left"

Which edge the drawer slides in from (on Drawer).

snapPoints = —
(number | string)[]

Heights the drawer snaps to, e.g. [0.4, 1]. Pair with activeSnapPoint / setActiveSnapPoint.

shouldScaleBackground = false
boolean

Scales the page behind the drawer for the iOS-style stacked look (on Drawer).

open = —
boolean

Controlled open state (on Drawer). Pair with onOpenChange.

Ten parts (Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerOverlay, DrawerPortal) built on vaul — the drag-to-dismiss drawer by emilkowalski. DrawerContent renders a drag handle for bottom drawers and is direction-aware (top/right/bottom/left). For nested drawers use vaul's NestedRoot directly. Animations require tw-animate-css.

Copied!
components/ui/drawer.tsx
"use client"

import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"

import { cn } from "@/lib/utils"

function Drawer({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
  return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}

function DrawerTrigger({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
  return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}

function DrawerPortal({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
  return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}

function DrawerClose({
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
  return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}

function DrawerOverlay({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
  return (
    <DrawerPrimitive.Overlay
      data-slot="drawer-overlay"
      className={cn(
        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
        className
      )}
      {...props}
    />
  )
}

function DrawerContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
  return (
    <DrawerPortal data-slot="drawer-portal">
      <DrawerOverlay />
      <DrawerPrimitive.Content
        data-slot="drawer-content"
        className={cn(
          "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
          "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
          "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
          className
        )}
        {...props}
      >
        <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
        {children}
      </DrawerPrimitive.Content>
    </DrawerPortal>
  )
}

function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-header"
      className={cn(
        "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
        className
      )}
      {...props}
    />
  )
}

function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="drawer-footer"
      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
      {...props}
    />
  )
}

function DrawerTitle({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
  return (
    <DrawerPrimitive.Title
      data-slot="drawer-title"
      className={cn("text-foreground font-semibold", className)}
      {...props}
    />
  )
}

function DrawerDescription({
  className,
  ...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
  return (
    <DrawerPrimitive.Description
      data-slot="drawer-description"
      className={cn("text-muted-foreground text-sm", className)}
      {...props}
    />
  )
}

export {
  Drawer,
  DrawerPortal,
  DrawerOverlay,
  DrawerTrigger,
  DrawerClose,
  DrawerContent,
  DrawerHeader,
  DrawerFooter,
  DrawerTitle,
  DrawerDescription,
}

Examples

Basic

A bottom drawer with a drag handle, header, and footer — drag down or press Escape to dismiss.

Packages

@designrevision/drawer
button

Props

No props documented yet.

Copied!
components/ui/drawer-demo-01.tsx
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export default function DrawerDemo01() {
  return (
    <Drawer>
      <DrawerTrigger className="inline-flex items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground">
        Open drawer
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Are you absolutely sure?</DrawerTitle>
            <DrawerDescription>
              This action cannot be undone. Drag the handle down or press Escape
              to dismiss.
            </DrawerDescription>
          </DrawerHeader>
          <DrawerFooter>
            <button className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90">
              Submit
            </button>
            <DrawerClose className="inline-flex items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground">
              Cancel
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Responsive (Dialog + Drawer)

A **Dialog on desktop** and a **Drawer on mobile** via `useMediaQuery`, sharing one form — the canonical shadcn responsive pattern.

Packages

@designrevision/drawer
dialog
button
input
label

Props

No props documented yet.

Copied!
components/ui/drawer-responsive-01.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

function useMediaQuery(query: string) {
  const [matches, setMatches] = React.useState(false)

  React.useEffect(() => {
    const result = window.matchMedia(query)
    const onChange = () => setMatches(result.matches)
    onChange()
    result.addEventListener("change", onChange)
    return () => result.removeEventListener("change", onChange)
  }, [query])

  return matches
}

function ProfileForm({ className }: React.ComponentProps<"form">) {
  return (
    <form className={cn("grid items-start gap-4", className)}>
      <div className="grid gap-2">
        <Label htmlFor="email">Email</Label>
        <Input type="email" id="email" defaultValue="[email protected]" />
      </div>
      <div className="grid gap-2">
        <Label htmlFor="username">Username</Label>
        <Input id="username" defaultValue="@ada" />
      </div>
      <Button type="submit">Save changes</Button>
    </form>
  )
}

export default function DrawerResponsive01() {
  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-[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>
    )
  }

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerTrigger asChild>
        <Button variant="outline">Edit profile</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader className="text-left">
          <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>
  )
}

Directions

Open from any edge — `top`, `right`, `bottom`, or `left` — with the `direction` prop on `Drawer`.

Packages

@designrevision/drawer
button

Props

No props documented yet.

Copied!
components/ui/drawer-directions-01.tsx
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

const directions = ["top", "right", "bottom", "left"] as const

export default function DrawerDirections01() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      {directions.map((direction) => (
        <Drawer key={direction} direction={direction}>
          <DrawerTrigger asChild>
            <Button variant="outline" className="capitalize">
              {direction}
            </Button>
          </DrawerTrigger>
          <DrawerContent>
            <DrawerHeader>
              <DrawerTitle className="capitalize">{direction} drawer</DrawerTitle>
              <DrawerDescription>
                This drawer slides in from the {direction}. Set it with the{" "}
                <code>direction</code> prop on <code>Drawer</code>.
              </DrawerDescription>
            </DrawerHeader>
            <DrawerFooter>
              <DrawerClose asChild>
                <Button variant="outline">Close</Button>
              </DrawerClose>
            </DrawerFooter>
          </DrawerContent>
        </Drawer>
      ))}
    </div>
  )
}

Snap points

A drawer that opens at 40% height and snaps to full when dragged — vaul `snapPoints={[0.4, 1]}`.

Packages

@designrevision/drawer
button

Props

No props documented yet.

Copied!
components/ui/drawer-snap-01.tsx
"use client"

import * as React from "react"

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

const snapPoints = [0.4, 1]

export default function DrawerSnap01() {
  const [snap, setSnap] = React.useState<number | string | null>(snapPoints[0])

  return (
    <Drawer
      snapPoints={snapPoints}
      activeSnapPoint={snap}
      setActiveSnapPoint={setSnap}
    >
      <DrawerTrigger asChild>
        <Button variant="outline">Open snap drawer</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Drag to expand</DrawerTitle>
          <DrawerDescription>
            This drawer opens at 40% height. Drag the handle up to snap it to
            full height.
          </DrawerDescription>
        </DrawerHeader>
        <div className="px-4">
          <p className="text-sm text-muted-foreground">
            Active snap point:{" "}
            <span className="font-medium text-foreground">
              {snap === 1 ? "full" : "40%"}
            </span>
          </p>
          <div className="mt-4 grid gap-3">
            {Array.from({ length: 6 }, (_, i) => i + 1).map((n) => (
              <div
                key={n}
                className="rounded-md border border-border p-3 text-sm"
              >
                Row {n} — scroll appears once the drawer is at full height.
              </div>
            ))}
          </div>
        </div>
        <DrawerFooter>
          <DrawerClose asChild>
            <Button variant="outline">Close</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}

Nested drawers

Stack a second drawer on the first with vaul `NestedRoot` — the one behind scales back to show the stack.

Packages

vaul
@designrevision/drawer
button

Props

No props documented yet.

Copied!
components/ui/drawer-nested-01.tsx
"use client"

// Nested drawers use vaul's NestedRoot, which our primitive intentionally does
// not re-export (to stay byte-faithful to shadcn/ui). Importing it directly is
// the documented vaul pattern for stacking one drawer on top of another.
import { Drawer as DrawerPrimitive } from "vaul"

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

export default function DrawerNested01() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">Open drawer</Button>
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>First drawer</DrawerTitle>
            <DrawerDescription>
              Open a nested drawer to stack a second panel on top — the one
              behind scales back to reveal the stack.
            </DrawerDescription>
          </DrawerHeader>
          <DrawerFooter>
            <DrawerPrimitive.NestedRoot>
              <DrawerTrigger asChild>
                <Button>Open nested drawer</Button>
              </DrawerTrigger>
              <DrawerContent>
                <div className="mx-auto w-full max-w-sm">
                  <DrawerHeader>
                    <DrawerTitle>Nested drawer</DrawerTitle>
                    <DrawerDescription>
                      Closing this returns you to the first drawer.
                    </DrawerDescription>
                  </DrawerHeader>
                  <DrawerFooter>
                    <DrawerClose asChild>
                      <Button variant="outline">Close</Button>
                    </DrawerClose>
                  </DrawerFooter>
                </div>
              </DrawerContent>
            </DrawerPrimitive.NestedRoot>
            <DrawerClose asChild>
              <Button variant="outline">Close</Button>
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Scrollable

A sticky header and footer with a scrollable body — `flex-1 overflow-y-auto` for long content.

Packages

@designrevision/drawer
button

Props

No props documented yet.

Copied!
components/ui/drawer-scroll-01.tsx
import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

const events = Array.from({ length: 20 }, (_, i) => i + 1)

export default function DrawerScroll01() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">View activity</Button>
      </DrawerTrigger>
      <DrawerContent className="max-h-[80vh]">
        <DrawerHeader className="border-b">
          <DrawerTitle>Activity</DrawerTitle>
        </DrawerHeader>
        <div className="flex-1 overflow-y-auto p-4">
          <div className="mx-auto grid w-full max-w-md gap-3">
            {events.map((n) => (
              <div
                key={n}
                className="rounded-md border border-border p-3 text-sm"
              >
                <p className="font-medium">Event #{n}</p>
                <p className="text-muted-foreground">
                  Something happened a little while ago.
                </p>
              </div>
            ))}
          </div>
        </div>
        <DrawerFooter className="border-t">
          <DrawerClose asChild>
            <Button>Close</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}

With a form

A **controlled** drawer holding a payment form that closes itself on submit via `setOpen(false)`.

Packages

@designrevision/drawer
button
input
label

Props

No props documented yet.

Copied!
components/ui/drawer-form-01.tsx
"use client"

import * as React from "react"

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

export default function DrawerForm01() {
  const [open, setOpen] = React.useState(false)

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerTrigger asChild>
        <Button variant="outline">Add payment method</Button>
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Add payment method</DrawerTitle>
            <DrawerDescription>
              Enter your card details. They are saved when you submit.
            </DrawerDescription>
          </DrawerHeader>
          <form
            onSubmit={(event) => {
              event.preventDefault()
              setOpen(false)
            }}
            className="grid gap-4 px-4"
          >
            <div className="grid gap-2">
              <Label htmlFor="card-name">Name on card</Label>
              <Input id="card-name" defaultValue="Ada Lovelace" />
            </div>
            <div className="grid gap-2">
              <Label htmlFor="card-number">Card number</Label>
              <Input id="card-number" defaultValue="4242 4242 4242 4242" />
            </div>
            <DrawerFooter className="px-0">
              <Button type="submit">Save card</Button>
              <DrawerClose asChild>
                <Button type="button" variant="outline">
                  Cancel
                </Button>
              </DrawerClose>
            </DrawerFooter>
          </form>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Command menu

A mobile **⌘K** — a [Command](/components/command) palette inside a drawer that closes when an item is picked.

Packages

lucide-react
@designrevision/drawer
command

Props

No props documented yet.

Copied!
components/ui/drawer-command-01.tsx
"use client"

import * as React from "react"
import {
  CalendarIcon,
  SearchIcon,
  SettingsIcon,
  SmileIcon,
  UserIcon,
} from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export default function DrawerCommand01() {
  const [open, setOpen] = React.useState(false)
  const close = () => setOpen(false)

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerTrigger asChild>
        <Button variant="outline">
          <SearchIcon />
          Search…
        </Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader className="sr-only">
          <DrawerTitle>Command menu</DrawerTitle>
        </DrawerHeader>
        <Command className="rounded-none border-t">
          <CommandInput placeholder="Type a command or search…" />
          <CommandList>
            <CommandEmpty>No results found.</CommandEmpty>
            <CommandGroup heading="Suggestions">
              <CommandItem onSelect={close}>
                <CalendarIcon />
                Calendar
              </CommandItem>
              <CommandItem onSelect={close}>
                <SmileIcon />
                Search emoji
              </CommandItem>
            </CommandGroup>
            <CommandGroup heading="Settings">
              <CommandItem onSelect={close}>
                <UserIcon />
                Profile
              </CommandItem>
              <CommandItem onSelect={close}>
                <SettingsIcon />
                Settings
              </CommandItem>
            </CommandGroup>
          </CommandList>
        </Command>
      </DrawerContent>
    </Drawer>
  )
}

Frequently asked questions

Yes — it is the canonical shadcn/ui drawer, built on vaul with the same ten parts and direction-aware styling. Official ships the primitive with a couple of demos; we add snap points, nested drawers, all four directions, a standalone responsive Dialog/Drawer, a scrollable layout, a controlled form, and a command menu.
Vaul is a small, accessible drawer library for React by Emil Kowalski — it powers the drag-to-dismiss gesture, momentum, snap points, background scaling, and nested stacking. The shadcn/ui Drawer is a thin styled wrapper over vaul, so you get native-feeling touch behavior without writing the physics yourself. Install it with npm install vaul.
A Drawer (vaul) is the touch-native panel with drag-to-dismiss and snap points — best on mobile and for bottom sheets. A [Sheet](/components/sheet) (Radix) slides from any edge without drag physics and is the better fit for desktop side panels. The most common setup is a Dialog or Sheet on desktop and a Drawer on mobile for the same content — see the Responsive example.
Pass snapPoints to Drawer — an array of heights as fractions (0.4) or CSS values ("300px"). Control the active stop with activeSnapPoint and setActiveSnapPoint. The drawer opens at the first snap point and the user can drag between them; content past the visible height scrolls once expanded. The Snap points example opens at 40% and snaps to full.
Detect the viewport with a useMediaQuery hook (matchMedia under the hood) and render a Dialog above your breakpoint or a Drawer below it, reusing the same form/content in both. The Responsive example does exactly this with (min-width: 768px). It is the pattern shadcn documents for the Drawer.
Pass the direction prop to Drawer: direction="top" | "right" | "bottom" | "left" (default "bottom"). The DrawerContent styling is direction-aware — bottom/top drawers get the drag handle and rounded edge, left/right drawers take a width (sm:max-w-sm). The Directions example shows all four.
Two things. First, the fade/slide utilities (animate-in, fade-in-0) come from tw-animate-css, not core Tailwind — add @import "tw-animate-css"; to your global CSS. Second, the drag and snap behavior is vaul itself, so make sure vaul is installed (npm install vaul). With both in place the drawer drags, snaps, and animates.