Skip to content

Drawer

A touch-first panel that slides from an edge, follows the finger, rests on snap points and dismisses with a swipe.

SourceBase UIRegistry
"use client"

import * as React from "react"
import { MinusIcon, PlusIcon } from "lucide-react"

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

export default function DrawerDemo() {
  const [goal, setGoal] = React.useState(350)

  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>
        Set daily goal
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Daily move goal</DrawerTitle>
            <DrawerDescription>
              Set how many active calories you want to burn each day.
            </DrawerDescription>
          </DrawerHeader>
          <div className="flex items-center justify-center gap-6 px-6 py-6">
            <Button
              variant="outline"
              size="icon-lg"
              className="rounded-full"
              aria-label="Decrease by 10"
              disabled={goal <= 200}
              onClick={() => setGoal((value) => value - 10)}
            >
              <MinusIcon />
            </Button>
            <div className="flex w-32 flex-col items-center">
              <output
                aria-live="polite"
                className="text-display-lg text-foreground tabular-nums"
              >
                {goal}
              </output>
              <span className="text-overline text-muted-foreground">
                kcal / day
              </span>
            </div>
            <Button
              variant="outline"
              size="icon-lg"
              className="rounded-full"
              aria-label="Increase by 10"
              disabled={goal >= 800}
              onClick={() => setGoal((value) => value + 10)}
            >
              <PlusIcon />
            </Button>
          </div>
          <DrawerFooter>
            <DrawerClose render={<Button size="lg" />}>Save goal</DrawerClose>
            <DrawerClose render={<Button variant="ghost" size="lg" />}>
              Cancel
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/drawer.json

Usage

import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer showSwipeHandle>
  <DrawerTrigger render={<Button variant="outline" />}>Set daily goal</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Daily move goal</DrawerTitle>
      <DrawerDescription>Set your daily activity goal.</DrawerDescription>
    </DrawerHeader>
    {/* … */}
    <DrawerFooter>
      <DrawerClose render={<Button size="lg" />}>Save goal</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>

The drawer is built on the Base UI Drawer: the panel tracks the pointer while it's dragged, dismisses when the gesture is fast or far enough, and settles on the nearest resting point instead of stopping wherever the finger let go.

Examples

Bottom sheet

The default: a drawer that rises from the bottom and is dismissed by swiping down. showSwipeHandle renders the grab handle that tells people it can be dragged. Center the content with a max-w-* wrapper so it stays readable on tablets — a share sheet is the archetype.

import {
  CopyIcon,
  DownloadIcon,
  LinkIcon,
  MailIcon,
  MessageCircleIcon,
  PrinterIcon,
  ShareIcon,
} from "lucide-react"

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

const targets = [
  { label: "Copy link", icon: LinkIcon },
  { label: "Messages", icon: MessageCircleIcon },
  { label: "Mail", icon: MailIcon },
  { label: "Duplicate", icon: CopyIcon },
  { label: "Download", icon: DownloadIcon },
  { label: "Print", icon: PrinterIcon },
]

export default function DrawerShare() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>
        <ShareIcon data-icon="inline-start" />
        Share
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Share “Q3 roadmap”</DrawerTitle>
            <DrawerDescription>
              Anyone with the link can view.
            </DrawerDescription>
          </DrawerHeader>
          <div className="grid grid-cols-3 gap-2 px-6 py-4">
            {targets.map((target) => (
              <DrawerClose
                key={target.label}
                render={
                  <button
                    type="button"
                    className="flex flex-col items-center gap-2 rounded-xl p-3 text-xs text-muted-foreground focus-ring transition-colors duration-fast hover:bg-accent hover:text-foreground"
                  />
                }
              >
                <span className="flex size-12 items-center justify-center rounded-full border bg-card text-foreground shadow-raised">
                  <target.icon className="size-5" />
                </span>
                {target.label}
              </DrawerClose>
            ))}
          </div>
          <DrawerFooter>
            <DrawerClose render={<Button variant="outline" size="lg" />}>
              Cancel
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Snap points

snapPoints lets the drawer rest at several heights. Fractions between 0 and 1 are portions of the viewport height; numbers above 1 are pixels, and strings accept px and rem. The drawer opens at the first point; drag it up to reach the next one.

import { MapPinIcon, StarIcon, XIcon } from "lucide-react"

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

const places = [
  { name: "Blue Bottle Coffee", kind: "Café", distance: "120 m", rating: 4.7 },
  { name: "Tartine Bakery", kind: "Bakery", distance: "350 m", rating: 4.8 },
  { name: "Dolores Park", kind: "Park", distance: "600 m", rating: 4.6 },
  { name: "Bi-Rite Market", kind: "Grocery", distance: "650 m", rating: 4.5 },
  { name: "Mission Dolores", kind: "Landmark", distance: "900 m", rating: 4.4 },
  {
    name: "Foreign Cinema",
    kind: "Restaurant",
    distance: "1.1 km",
    rating: 4.6,
  },
  { name: "The Chapel", kind: "Music venue", distance: "1.2 km", rating: 4.7 },
  {
    name: "Clarion Alley",
    kind: "Street art",
    distance: "1.3 km",
    rating: 4.5,
  },
]

export default function DrawerSnapPoints() {
  return (
    <Drawer showSwipeHandle snapPoints={[0.45, 1]}>
      <DrawerTrigger render={<Button variant="outline" />}>
        <MapPinIcon data-icon="inline-start" />
        Nearby places
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Nearby</DrawerTitle>
          <DrawerDescription>
            Drag up to see all {places.length} places.
          </DrawerDescription>
        </DrawerHeader>
        {/* At the top, not in a footer: at the first snap point the bottom of
            the drawer is off-screen, and a button placed there would be too. */}
        <DrawerClose
          render={
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Close"
              className="absolute top-4 right-4"
            />
          }
        >
          <XIcon />
        </DrawerClose>
        <ul className="flex flex-col overflow-y-auto px-3 pb-6">
          {places.map((place) => (
            <li
              key={place.name}
              className="flex items-center gap-3 rounded-lg px-3 py-3 hover:bg-accent"
            >
              <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-surface text-muted-foreground">
                <MapPinIcon className="size-4" />
              </span>
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="truncate text-sm font-medium text-foreground">
                  {place.name}
                </span>
                <span className="text-xs text-muted-foreground">
                  {place.kind} · {place.distance}
                </span>
              </span>
              <span className="flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
                <StarIcon className="size-3 fill-current text-warning-9" />
                {place.rating.toLocaleString("en-US", {
                  minimumFractionDigits: 1,
                })}
              </span>
            </li>
          ))}
        </ul>
      </DrawerContent>
    </Drawer>
  )
}
<Drawer showSwipeHandle snapPoints={[0.45, 1]}>

Directions

swipeDirection sets both the edge and the dismiss gesture: down (default), up, left or right. Side drawers are 75% wide on phones and 24rem from sm up.

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

const directions = [
  {
    direction: "right",
    label: "From right",
    hint: "Swipe right or press Esc to dismiss it.",
  },
  {
    direction: "left",
    label: "From left",
    hint: "Swipe left or press Esc to dismiss it.",
  },
  {
    direction: "up",
    label: "From top",
    hint: "Swipe up or press Esc to dismiss it.",
  },
] as const

export default function DrawerDirections() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {directions.map(({ direction, label, hint }) => (
        <Drawer key={direction} swipeDirection={direction} showSwipeHandle>
          <DrawerTrigger render={<Button variant="outline" />}>
            {label}
          </DrawerTrigger>
          <DrawerContent>
            <div className="flex flex-1 flex-col">
              <DrawerHeader>
                <DrawerTitle>Swipe to dismiss</DrawerTitle>
                <DrawerDescription>{hint}</DrawerDescription>
              </DrawerHeader>
              <div className="mx-6 my-2 flex min-h-24 flex-1 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
                Content
              </div>
              <DrawerFooter>
                <DrawerClose render={<Button variant="outline" />}>
                  Close
                </DrawerClose>
              </DrawerFooter>
            </div>
          </DrawerContent>
        </Drawer>
      ))}
    </div>
  )
}

Nested

Open a drawer from inside another and the parent steps back into a stack — slightly smaller, slightly dimmed — so people see where they'll return. Swiping the child away brings the parent forward again.

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

export default function DrawerNested() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>
        Checkout
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Order summary</DrawerTitle>
            <DrawerDescription>2 items · Ships in 1–2 days</DrawerDescription>
          </DrawerHeader>
          <dl className="flex flex-col gap-2 px-6 py-4 text-sm">
            <div className="flex justify-between">
              <dt className="text-muted-foreground">Subtotal</dt>
              <dd className="tabular-nums">$148.00</dd>
            </div>
            <div className="flex justify-between">
              <dt className="text-muted-foreground">Shipping</dt>
              <dd className="tabular-nums">$6.00</dd>
            </div>
            <div className="flex justify-between border-t pt-2 font-medium">
              <dt>Total</dt>
              <dd className="tabular-nums">$154.00</dd>
            </div>
          </dl>
          <DrawerFooter>
            {/* A drawer inside a drawer: the parent steps back into a stack. */}
            <Drawer showSwipeHandle>
              <DrawerTrigger render={<Button size="lg" />}>
                Add a promo code
              </DrawerTrigger>
              <DrawerContent>
                <div className="mx-auto w-full max-w-sm">
                  <DrawerHeader>
                    <DrawerTitle>Promo code</DrawerTitle>
                    <DrawerDescription>
                      Codes are case-insensitive.
                    </DrawerDescription>
                  </DrawerHeader>
                  <div className="px-6 py-2">
                    <Field>
                      <FieldLabel htmlFor="promo-code">Code</FieldLabel>
                      <Input id="promo-code" placeholder="SPRING26" />
                    </Field>
                  </div>
                  <DrawerFooter>
                    <DrawerClose render={<Button size="lg" />}>
                      Apply
                    </DrawerClose>
                  </DrawerFooter>
                </div>
              </DrawerContent>
            </Drawer>
            <DrawerClose render={<Button variant="ghost" size="lg" />}>
              Back to cart
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Responsive

On desktop, a centered Dialog is easier to reach with a pointer; on phones, a bottom drawer is within thumb reach. Share the content and switch the container with useIsMobile.

"use client"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useIsMobile } from "@/hooks/use-mobile"

function InviteForm() {
  return (
    <FieldGroup>
      <Field>
        <FieldLabel htmlFor="invite-email">Email</FieldLabel>
        <Input id="invite-email" type="email" placeholder="grace@company.com" />
      </Field>
    </FieldGroup>
  )
}

/** A dialog on desktop, a bottom drawer below the `md` breakpoint. */
export default function DrawerResponsive() {
  const isMobile = useIsMobile()
  const title = "Invite teammate"
  const description = "They’ll get an email with a link to join Acme."

  if (isMobile) {
    return (
      <Drawer showSwipeHandle>
        <DrawerTrigger render={<Button variant="outline" />}>
          {title}
        </DrawerTrigger>
        <DrawerContent>
          <DrawerHeader className="text-left">
            <DrawerTitle>{title}</DrawerTitle>
            <DrawerDescription>{description}</DrawerDescription>
          </DrawerHeader>
          <div className="px-6 py-2">
            <InviteForm />
          </div>
          <DrawerFooter>
            <Button size="lg">Send invite</Button>
            <DrawerClose render={<Button variant="ghost" size="lg" />}>
              Cancel
            </DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    )
  }

  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        {title}
      </DialogTrigger>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <InviteForm />
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>
            Cancel
          </DialogClose>
          <Button>Send invite</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
const isMobile = useIsMobile()

return isMobile ? (
  <Drawer showSwipeHandle>{/* … */}</Drawer>
) : (
  <Dialog>{/* … */}</Dialog>
)

Guidelines

When to use

  • For contextual tasks on touch devices: pickers, short forms, share sheets, filters.
  • When the content benefits from resting at several heights, like a map with a list of places.

When not to use

  • On pointer-first desktop layouts — use a Dialog or a Sheet.
  • For destructive confirmations — use an Alert Dialog, which can't be swiped away by accident.
  • For long, complex forms — give them a page.

Gestures are a shortcut, never the only way

Everything a gesture does must also be possible with a tap or a key. Keep a visible close or cancel action in the footer, and don't rely on swiping between snap points to reveal essential content.

Share album24 photos
DoA visible action closes the drawer; the swipe is a shortcut.
Share albumSwipe down to close
Don’tHidden gestures are invisible to screen reader and keyboard users.

Thumb reach

Put primary actions at the bottom of the drawer, full width, in the lg size (40px tall). That's where thumbs are, and it clears the 24px minimum target comfortably.

Accessibility

The drawer is a modal dialog: it traps focus, locks page scroll, and is labeled by DrawerTitle and described by DrawerDescription.

KeyBehavior
EnterSpace
On the trigger: opens the drawer and moves focus into it.
Tab
Moves through the drawer's controls. Focus is trapped while it's open.
ShiftTab
Moves focus backwards.
Esc
Closes the drawer and returns focus to the trigger.
  • Always include a title. It names the drawer for assistive technology; hide it with sr-only if the design has none.
  • The swipe handle is decorative and hidden from assistive technology — provide a real close button.
  • Motion. The drawer follows the pointer 1:1 while dragging and settles over 480ms (duration-slower). With reduced motion, the settle animation is removed; dragging still tracks the finger, because that motion is caused by the person, not by the interface.

API reference

Drawer

The root. Doesn't render an element. Accepts every prop of Base UI Drawer.Root.

PropTypeDefault
swipeDirection

The edge the drawer is attached to, and the swipe that dismisses it.

"down" | "up" | "left" | "right""down"
snapPoints

Resting positions. 0–1 is a fraction of the viewport height, > 1 is pixels, strings accept px and rem.

(number | string)[]No default
snapPoint

The active snap point. Use with onSnapPointChange to control it.

number | string | nullNo default
defaultSnapPoint

The initial snap point, when uncontrolled.

number | string | nullNo default
onSnapPointChange

Called when the drawer settles on another snap point.

(snapPoint, details) => voidNo default
snapToSequentialPoints

Moves one snap point at a time instead of skipping on fast swipes.

booleanfalse
showSwipeHandle

Renders the grab handle. prfct-specific.

booleanfalse
open

Whether the drawer is open. Use with onOpenChange to control it.

booleanNo default
defaultOpen

Whether it is open initially, when uncontrolled.

booleanfalse
onOpenChange

Called when it opens or closes. details.reason is "swipe" for gestures.

(open: boolean, details) => voidNo default
modal

true traps focus, locks page scroll and blocks the page behind a scrim. "trap-focus" traps focus but leaves the page scrollable and clickable. false keeps the page interactive. The scrim renders only for true.

boolean | "trap-focus"true
disablePointerDismissal

Prevents closing when clicking the scrim.

booleanfalse

DrawerContent

Renders the portal, scrim, viewport and panel; the scrim is left out unless modal is true. Accepts every prop of Base UI Drawer.Popup, including initialFocus and finalFocus.

The content area scrolls when it is taller than the drawer may grow: vertical drawers are capped at 100dvh - 6rem, which leaves little room on a phone in landscape. The header and footer scroll with it. To keep them in place, wrap the long part in its own element with overflow-y-auto, as the snap points example does with its list.

DrawerPortal, DrawerOverlay (the scrim) and DrawerSwipeHandle (the grab handle) are the pieces DrawerContent assembles. They are exported for custom compositions; you don't need them otherwise.

DrawerHeader, DrawerFooter

DrawerHeader holds the title and description — centered on vertical drawers below md, left-aligned above. DrawerFooter stacks actions full width at the bottom.

DrawerTitle, DrawerDescription

DrawerTitle renders an <h2> and names the drawer. DrawerDescription renders a <p> and describes it.

DrawerTrigger, DrawerClose

Open and close the drawer. Both render a <button>; use render to render a prfct Button.