# Drawer

> A React drawer and bottom sheet built on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop.

- Type: React component (Overlays)
- Access: Free (the source is open to everyone)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add drawer`
- Docs: https://wingo-ui.com/components/drawer

## Interaction

Tap the trigger and the sheet slides up from the bottom on the iOS sheet curve while the page dims. Drag it down by the handle or anywhere on it and it follows your finger; let go past a quarter of its height, or flick it, and it closes, otherwise it settles back. A long list inside scrolls first and only drags the sheet once it is back at the top; the header hairline appears as soon as the list scrolls and the footer hairline while there is more below. Snap points stop it half open and a tap on the handle steps to the next point. A drawer opened from inside another one pushes the first one back, like iOS. Escape, a tap on the dim page or the close button close it, unless it is not dismissible. Opening moves focus to the sheet itself (no field pops the phone keyboard, Tab goes on to the first control; the desktop dialog focuses its first text field, or itself when there is none), keeps it inside while open and returns it to the trigger after. A shortcut, when set, toggles it from anywhere. From 768px it stays a centered sheet, or becomes a side panel from the right or a centered dialog, whichever desktop asks for. With reduced motion the sheet's slide ends in 200ms and the dialog only fades.

## Installation

```bash
npx wingo-ui@latest add drawer
```

The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add https://wingo-ui.com/r/drawer.json`

Files:

- `components/ui/drawer.tsx`

npm dependencies: `npm i @radix-ui/react-dialog @radix-ui/react-visually-hidden lucide-react motion vaul`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { SlidersHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Drawer, DrawerClose } from "@/components/ui/drawer"

export function ListingFilters({ results, onReset }: { results: number; onReset: () => void }) {
  return (
    <Drawer
      trigger={
        <Button variant="soft" leftIcon={<SlidersHorizontal />}>
          Filters
        </Button>
      }
      title="Filters"
      description="New York · 1,240 listings"
      desktop="side"
      closeLabel="Close filters"
      shortcut="mod+j"
      footer={
        <>
          <DrawerClose asChild>
            <Button size="lg" fullWidth>
              Show {results} results
            </Button>
          </DrawerClose>
          <Button size="lg" variant="ghost" fullWidth onClick={onReset}>
            Reset
          </Button>
        </>
      }
    >
      <p className="text-sm text-muted-foreground">Price, condition and categories</p>
    </Drawer>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen?` | `boolean` | `false` | Starts open (uncontrolled) and skips the entrance animation. Pass open and onOpenChange instead to control it. |
| `open?` | `boolean` |  | Controlled open state. The root keeps it across the 768px breakpoint, so a resize never closes the drawer. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when the drawer opens or closes, by the trigger, a drag, Escape, an outside tap or a close button. |
| `title?` | `ReactNode` | `""` | The title under the handle, also the dialog's accessible name. The demo shows its sample while this is empty. |
| `description?` | `ReactNode` | `""` | A quieter line under the title, linked with aria-describedby. Without one the drawer opts out of the description (Radix's aria-describedby={undefined}). |
| `hideTitle?` | `boolean` | `false` | Keeps the title for screen readers only, for sheets whose content speaks for itself (a share sheet, a photo). |
| `closeLabel?` | `string` | `"Close"` | aria-label of the close button, e.g. “Close filters”. The demo spells it out under the page. |
| `trigger?` | `ReactNode` |  | The element that opens the drawer, rendered with asChild: a single element that takes a ref, like a Button. It gets aria-keyshortcuts when a shortcut is set. |
| `children?` | `ReactNode` |  | The body. It scrolls on its own (overscroll contained), pads the home indicator when it is the last part, and drives the header and footer hairlines. |
| `footer?` | `ReactNode` |  | Actions pinned under the body, primary first: stacked full width in sheets (primary on top), a row with the primary on the right in side and dialog modes. Give sheet buttons size="lg" fullWidth. |
| `direction?` | `"bottom" \| "top" \| "left" \| "right"` | `"bottom"` | The edge it slides in from. Bottom and top sheets get the handle; left and right panels are 320 to 540px wide, always show the close button, and leave a tappable sliver of the page on phones. |
| `desktop?` | `"sheet" \| "side" \| "dialog"` | `"sheet"` | What it becomes at 768px and up. sheet: the same drawer, capped and centered. side: a panel floating 8px off the right edge. dialog: a centered dialog with a spring (no drag). Phones always get the sheet. |
| `size?` | `"sm" \| "md" \| "lg" \| "full"` | `"md"` | Bottom and top sheets on desktop: max width 420 / 560 / 720px or the whole screen. Left, right and side panels: 320 / 400 / 540px or the whole screen. Dialog: 400 / 520 / 720px or 16px from every edge. |
| `height?` | `"auto" \| "full"` | `"auto"` | auto grows with the content up to 92% of the screen; full always takes 92%. Sheets with snap points are full. |
| `variant?` | `"attached" \| "floating"` | `"attached"` | attached sits on the screen edge with the outer corners square; floating keeps 8px off every edge and the safe area, with every corner rounded (the iOS 26 look). The desktop side panel always floats. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg"` | `"lg"` | Sheet corners 0 / 16 / 24 / 28px (28px is the design system's sheet radius). In dialog mode 0 / 12 / 16 / 24px. Any value through className="[--drawer-radius:20px]". |
| `showHandle?` | `boolean` | `true` | The drag handle of bottom and top sheets. A tap on it steps through the snap points. A sheet that is not dismissible and has no snap points hides it, since there is nothing to drag (like iOS). |
| `showClose?` | `boolean` | `false` | Adds the round close button to the corner of bottom and top sheets. Panels and dialogs always have one, since they have no handle. |
| `overlay?` | `boolean` | `true` | The dim backdrop. Turned off, the invisible layer stays, so the page still does not scroll and a tap outside still closes. There is never one with modal={false}. |
| `headerAlign?` | `"start" \| "center"` | `"start"` | Title and description alignment. center reads like an iOS sheet; the close button keeps both sides even. |
| `dismissible?` | `boolean` | `true` | false: no drag to close, no outside tap, no Escape and no shortcut close, and no handle unless there are snap points. Only a close button or an action inside (DrawerClose, or setting open) closes it, e.g. terms that must be accepted. |
| `modal?` | `boolean` | `true` | false: no overlay, no focus trap and no scroll lock; the page behind stays usable and readable, like a results sheet over a map. Focus is not moved into it on open. |
| `handleOnly?` | `boolean` | `false` | Drags only from the handle, for content with its own horizontal gestures (a carousel, a map, a slider). |
| `scaleBackground?` | `boolean` | `false` | Scales the page back behind the sheet like iOS. Needs vaul-drawer-wrapper="" (or data-vaul-drawer-wrapper="") on the element that wraps your app, e.g. <div vaul-drawer-wrapper="" className="min-h-dvh bg-background">. The demo marks its listings page. |
| `repositionInputs?` | `boolean` | `true` | Moves the sheet up when the on-screen keyboard opens so the focused field stays visible. Turn it off for sheets that handle the keyboard themselves. |
| `shortcut?` | `string` | `""` | A key combo in mod+k notation (⌘ on Apple devices, Ctrl elsewhere) that toggles the drawer from anywhere on the page; the trigger gets aria-keyshortcuts. Empty binds nothing. |
| `snapPoints?` | `(number \| string)[]` |  | Stops on the way up: fractions of the screen ([0.45, 1]) or px strings (["320px", 1]). The sheet becomes full height, starts at the first point, the body only scrolls at the last one, and a tap on the handle steps to the next. Ignored in desktop dialog mode. |
| `activeSnapPoint?` | `number \| string \| null` |  | The current snap point, controlled. Uncontrolled it starts at the first point and returns there after a close. |
| `onActiveSnapPointChange?` | `(point: number \| string \| null) => void` |  | Called when a drag or a handle tap settles on another snap point. |
| `fadeFromIndex?` | `number` |  | The snap point index from which the overlay dims. Defaults to the last point, so a half open sheet leaves the page bright. |
| `container?` | `HTMLElement \| null` |  | Portal target, document.body by default. Inside a transformed or contain:layout element the sheet, its snap points and its 92% height stay within that element (the map example). |
| `onAnimationEnd?` | `(open: boolean) => void` |  | Runs after the open or close animation with the state it ended in, e.g. to reset a form once the sheet is gone. |
| `classNames?` | `Partial<Record<"overlay" \| "content" \| "handle" \| "close" \| "header" \| "title" \| "description" \| "body" \| "footer", string>>` |  | Extra classes for the inner parts: the backdrop, the sheet, the handle, the close button, and the header, title, description, body and footer the simple API renders (the same keys as the sheet). |
| `className?` | `string` |  | Extra classes for the sheet (drawer-content), merged last. Set [--drawer-radius:20px] here for a custom corner. |

## Parts

### DrawerRoot (component)

Compound API: holds the state and picks the mode (vaul sheet on phones; sheet, side panel or Radix dialog from 768px). No DOM. Rendered inside another drawer's content it nests on its own. The popover, dialog, dropdown and context menu sheets are built from these parts. Props: { open, defaultOpen, onOpenChange, direction, desktop, snapPoints, activeSnapPoint, onActiveSnapPointChange, fadeFromIndex, dismissible, modal, handleOnly, scaleBackground, repositionInputs, shortcut, container, onAnimationEnd }.

### DrawerContent (component)

The portal, the overlay (drawer-overlay), the sheet (drawer-content with data-mode, data-direction, data-variant, data-size), the handle and the close button. Needs a DrawerTitle inside (visuallyHidden if the design has none). Props: { size, height, variant, radius, showHandle, showClose, closeLabel, overlay, container, classNames } & Radix Dialog.Content props.

### DrawerHeader (component)

The title row and description. Its bottom hairline fades in once the body scrolls. Props: { title?, description?, hideTitle?, align?: "start" | "center" }.

### DrawerTitle (component)

17px semibold in sheets, 18px in panels and dialogs. visuallyHidden keeps it for screen readers only. Props: { visuallyHidden?: boolean } & Radix Dialog.Title props.

### DrawerDescription (component)

Muted 14px text, wired to aria-describedby. Props: Radix Dialog.Description props.

### DrawerBody (component)

The scroll region. Reports its scroll edges to the header and footer, pads the safe area when it is the last part, and stays still below the last snap point so the sheet drags instead. Takes div props.

### DrawerFooter (component)

Pinned actions, primary first. Stacked in sheets, a row in side and dialog modes; its top hairline shows while the body has more below. Takes div props.

### DrawerTrigger (component)

Opens the drawer from any element. Takes Radix Dialog.Trigger props (asChild).

### DrawerClose (component)

Closes the drawer from any element, also one that is not dismissible. Takes Radix Dialog.Close props (asChild).

### useDrawer (hook)

The nearest drawer's state, e.g. to close it after a form saves or to lay out differently in dialog mode. Signature: () => { open: boolean, setOpen: (open: boolean) => void, mode: "sheet" | "side" | "dialog", direction }.

## Examples

- **Snap points over a map**: snapPoints={[0.45, 1]} and modal={false}: it starts half open and the pins behind stay tappable. Drag it up to scroll the list.
- **Nested**: A drawer opened from inside another one pushes the first one back, like iOS. The new address lands in the list.
- **Side panel on desktop**: desktop="side": a floating cart panel from 768px, the same drawer as a sheet on a phone. Total in en-US.
- **Dialog on desktop**: desktop="dialog": a centered dialog with a spring; useDrawer() sizes the buttons for the mode, onAnimationEnd resets the form.
- **Floating share sheet**: variant="floating" keeps 8px off every edge with all corners round; the header is centered.
- **Not dismissible**: dismissible={false}: no drag, handle, Escape or outside tap. Only accepting the terms closes it; read again later, it closes like any sheet.
- **From the top**: direction="top": notifications drop in from above with the handle at the bottom edge.

## See also

- [Sheet](https://wingo-ui.com/components/sheet) ([Markdown](https://wingo-ui.com/components/sheet.md))
- [Dialog](https://wingo-ui.com/components/dialog) ([Markdown](https://wingo-ui.com/components/dialog.md))
- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Family Drawer](https://wingo-ui.com/components/family-drawer) ([Markdown](https://wingo-ui.com/components/family-drawer.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))

---

Canonical: https://wingo-ui.com/components/drawer
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
