# Drawer

An accessible React drawer for Next.js and Tailwind CSS: an iOS bottom sheet on phones that drags to close and works with the keyboard, a dialog on larger screens.

Source: https://ui.fewtone.com/components/drawer

## Installation

```bash
npx shadcn@latest add https://ui.fewtone.com/r/drawer.json
```

## Usage

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

export function MoveToTrash() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">Move to trash</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Move to trash?</DrawerTitle>
          <DrawerDescription>You can restore it for 30 days.</DrawerDescription>
        </DrawerHeader>
        <DrawerFooter>
          <DrawerClose asChild>
            <Button variant="outline">Cancel</Button>
          </DrawerClose>
          <Button variant="destructive">Move to trash</Button>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}

// For the page to step back behind the sheet, wrap your app once (app/layout.tsx):
// <body>
//   <div data-vaul-drawer-wrapper="" className="min-h-svh bg-background">{children}</div>
// </body>
// and let content reach the screen's edges, so the sheet clears the home indicator:
// export const viewport = { viewportFit: "cover" }
```

## API reference

### Drawer

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `"auto" \| "sheet" \| "dialog"` | `"auto"` | A sheet on phones (under 768px) and a dialog elsewhere, or always one of them. |
| `open` | `boolean` | – | Whether it's open (controlled). |
| `defaultOpen` | `boolean` | `false` | Open on first render. |
| `onOpenChange` | `(open: boolean) => void` | – | Called as it opens and closes, by any means (a drag, Escape, the backdrop, a button). |

### DrawerTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### DrawerContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | `true` | Show a close button in the top corner. |

### DrawerHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### DrawerFooter

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### DrawerTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### DrawerDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### DrawerClose

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

**When to use it:** Use Drawer for anything that should open as an iOS bottom sheet on phones (forms, confirmations, pickers, details) and as a dialog on larger screens. Use Sheet for a panel from the side beside the page, and Dialog when it should always be a centred window. Wrap the app in data-vaul-drawer-wrapper so the page steps back.
**Built on:** Vaul, Radix Dialog.
**Accessibility pattern:** https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
**npm packages (installed by the CLI):** lucide-react@^1.52.0, motion@^14.0.0, vaul@1.1.2.
**Installs these FewTone UI components with it:** `button`, `dialog`.

## Examples

### Edit profile

A form in a sheet. On a phone, the sheet sits on the keyboard and keeps the focused field in view above the pinned buttons. The setting picks what it opens as: an app usually leaves it on auto, a sheet on phones and a dialog elsewhere.

```tsx
"use client";

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 {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useId, useState } from "react";

type Mode = "sheet" | "auto" | "dialog";

const modes: { value: Mode; label: string }[] = [
  { value: "sheet", label: "A sheet" },
  { value: "auto", label: "Sheet on phones" },
  { value: "dialog", label: "A dialog" },
];

/*
 * A form in a drawer. On a phone, focus a field: the sheet sits on the keyboard, the field stays in view above
 * the pinned buttons, and scrolling while typing never closes it. Saving (the button, or Enter in a field)
 * closes it, so it's controlled. The setting picks what it opens as; an app usually leaves it on auto.
 */
export default function DrawerProfile() {
  const id = useId();
  const [mode, setMode] = useState<Mode>("sheet");
  const [open, setOpen] = useState(false);

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-mode`} className="text-sm text-muted-foreground">
          Opens as
        </label>
        <Select value={mode} onValueChange={(value) => setMode(value as Mode)}>
          <SelectTrigger id={`${id}-mode`} size="sm" className="w-40">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {modes.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 items-center justify-center">
        <Drawer mode={mode} open={open} onOpenChange={setOpen}>
          <DrawerTrigger asChild>
            <Button variant="outline">Edit profile</Button>
          </DrawerTrigger>
          <DrawerContent className="sm:max-w-md">
            <form
              className="grid gap-6"
              onSubmit={(event) => {
                event.preventDefault();
                setOpen(false);
              }}
            >
              <DrawerHeader>
                <DrawerTitle>Edit profile</DrawerTitle>
                <DrawerDescription>Change how others see you. Save when you're done.</DrawerDescription>
              </DrawerHeader>
              <div className="grid gap-4">
                <div className="grid gap-2">
                  <label htmlFor={`${id}-name`} className="text-sm font-medium">
                    Name
                  </label>
                  <Input id={`${id}-name`} defaultValue="Maya Chen" autoComplete="name" />
                </div>
                <div className="grid gap-2">
                  <label htmlFor={`${id}-username`} className="text-sm font-medium">
                    Username
                  </label>
                  <Input id={`${id}-username`} defaultValue="maya" autoComplete="username" />
                </div>
                <div className="grid gap-2">
                  <label htmlFor={`${id}-bio`} className="text-sm font-medium">
                    Bio
                  </label>
                  <Textarea
                    id={`${id}-bio`}
                    defaultValue="Designer in Lisbon. I make calm interfaces."
                    minRows={3}
                    maxRows={6}
                  />
                </div>
              </div>
              <DrawerFooter>
                <DrawerClose asChild>
                  <Button variant="outline">Cancel</Button>
                </DrawerClose>
                <Button type="submit">Save changes</Button>
              </DrawerFooter>
            </form>
          </DrawerContent>
        </Drawer>
      </div>
    </div>
  );
}
```

### Long content

The sheet grows to just under the status bar; its content scrolls on its own and the buttons stay pinned.

```tsx
"use client";

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

const sections = [
  {
    title: "Your account",
    text: "You're responsible for what happens under your account, and for keeping its password safe.",
  },
  {
    title: "Your content",
    text: "What you upload stays yours. You let us store and show it so the service can work.",
  },
  {
    title: "Acceptable use",
    text: "Don't break the law, harm others, or try to get round the limits of your plan.",
  },
  {
    title: "Payments",
    text: "Paid plans renew each month until you cancel. Cancelling stops the next renewal.",
  },
  {
    title: "Privacy",
    text: "We collect only what the service needs. The privacy policy says what, and why.",
  },
  {
    title: "Changes",
    text: "We'll tell you about changes that matter at least 30 days before they apply.",
  },
  {
    title: "Ending",
    text: "You can close your account at any time. We may close one that breaks these terms.",
  },
];

/*
 * Long content: the sheet grows to just under the status bar, its body scrolls on its own, and the buttons
 * stay pinned at the bottom. Drag it down from the top of the text, or by the grabber, to close it.
 */
export default function DrawerScrolling() {
  return (
    <Drawer mode="sheet">
      <DrawerTrigger asChild>
        <Button variant="outline">Read the terms</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Terms of service</DrawerTitle>
          <DrawerDescription>Updated 1 October 2026</DrawerDescription>
        </DrawerHeader>
        {sections.map((section) => (
          <section key={section.title} className="grid gap-1.5">
            <h3 className="text-sm font-medium">{section.title}</h3>
            <p className="text-sm text-muted-foreground">{section.text}</p>
          </section>
        ))}
        <DrawerFooter>
          <DrawerClose asChild>
            <Button variant="outline">Decline</Button>
          </DrawerClose>
          <DrawerClose asChild>
            <Button>Accept</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}
```

### A sheet on a sheet

A drawer opened from a sheet: the first steps back behind the second.

```tsx
"use client";

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

const people = ["Olivia Martin", "Jackson Lee", "Isabella Nguyen"];

/*
 * A sheet opened from a sheet: the first steps back behind the second, and comes forward again when it closes.
 * The inner drawer follows its parent, so it's a sheet here too.
 */
export default function DrawerNested() {
  return (
    <Drawer mode="sheet">
      <DrawerTrigger asChild>
        <Button variant="outline">Share</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Share the Q4 plan</DrawerTitle>
          <DrawerDescription>These people can open it.</DrawerDescription>
        </DrawerHeader>
        <ul className="grid gap-2 text-sm">
          {people.map((person) => (
            <li key={person}>{person}</li>
          ))}
        </ul>
        <DrawerFooter>
          <Drawer mode="sheet">
            <DrawerTrigger asChild>
              <Button>Add people</Button>
            </DrawerTrigger>
            <DrawerContent>
              <DrawerHeader>
                <DrawerTitle>Add people</DrawerTitle>
                <DrawerDescription>They'll get an email with a link.</DrawerDescription>
              </DrawerHeader>
              <DrawerFooter>
                <DrawerClose asChild>
                  <Button>Done</Button>
                </DrawerClose>
              </DrawerFooter>
            </DrawerContent>
          </Drawer>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}
```

## Related

- [Dialog](https://ui.fewtone.com/components/dialog.md): An accessible React dialog for Next.js and Tailwind CSS: a modal window that grows out of its trigger, keeps focus inside and returns it, built on Radix Dialog.
- [Sheet](https://ui.fewtone.com/components/sheet.md): An accessible React sheet for Next.js: a side panel that slides in from any edge of the screen, for forms, filters and navigation, built on Radix Dialog.
- [Alert Dialog](https://ui.fewtone.com/components/alert-dialog.md): An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.
