---
type: doc
title: Drawer
description: A drawer component that slides in from any edge of the screen.
---

> Fulldev UI for Astro. The `@fulldev` registry in `components.json` points at `https://ui.full.dev/r/styles/{style}/{name}.json`; see [installation](/astro/docs/installation.md).

```astro title="src/components/examples/drawer/drawer-demo.astro"
---
import { Badge } from "@/components/ui/badge"
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"
import { Toaster } from "@/components/ui/toast"

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",
  },
]
---

<Drawer swipeDirection="right" data-drawer-demo data-drawer-responsive>
  <DrawerTrigger variant="secondary">Open Drawer</DrawerTrigger>
  <DrawerContent showSwipeHandle>
    <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>
            <Field as="span" orientation="horizontal">
              <FieldContent as="span">
                <FieldTitle as="span" class="flex items-center gap-2">
                  {time.label}
                  {time.badge ? (
                    <Badge variant="secondary">{time.badge}</Badge>
                  ) : null}
                </FieldTitle>
                <FieldDescription as="span">
                  {time.description}
                </FieldDescription>
              </FieldContent>
              <RadioGroupItem
                value={time.value}
                id={time.id}
                data-label={time.label}
              />
            </Field>
          </FieldLabel>
        ))}
      </RadioGroup>
    </div>
    <DrawerFooter>
      <DrawerClose class="h-[34px]" data-drawer-demo-confirm>
        Confirm Delivery Time
      </DrawerClose>
      <DrawerClose variant="outline">Cancel</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
  <Toaster portal />
</Drawer>

<script>
  import { createDrawer } from "@data-slot/drawer"

  // Like useIsMobile: a bottom drawer with a swipe handle below 768px.
  const query = matchMedia("(max-width: 767px)")
  const triggerOf = (root: HTMLElement) =>
    [...root.querySelectorAll('[data-slot="drawer-trigger"]')].find(
      (element) => element.closest('[data-slot="drawer"]') === root
    )
  const update = () => {
    const direction = query.matches ? "down" : "right"
    const roots = [
      ...document.querySelectorAll<HTMLElement>("[data-drawer-responsive]"),
    ]
    for (const root of roots) {
      // An open popup lives in the body, outside its root.
      const popup = document.getElementById(
        triggerOf(root)?.getAttribute("aria-controls") ?? ""
      )
      for (const parent of [root, popup])
        parent
          ?.querySelectorAll('[data-slot="drawer-handle"]')
          .forEach((handle) => handle.toggleAttribute("hidden", !query.matches))
    }
    // Data Slot reads the direction when it binds, and restores it when
    // destroyed, so rebind. Open drawers stay open: nested ones unbind first
    // and bind last.
    const stale = roots.filter(
      (root) => root.dataset.swipeDirection !== direction
    )
    const open = stale.map((root) => createDrawer(root).isOpen)
    for (const root of [...stale].reverse()) createDrawer(root).destroy()
    stale.forEach((root, index) => {
      root.dataset.swipeDirection = direction
      const drawer = createDrawer(root, { open: open[index] })
      // Reselect the trigger, which gets aria-expanded back.
      const trigger = triggerOf(root)
      if (open[index] && trigger?.id) drawer.open(trigger.id)
    })
  }

  update()
  document.addEventListener("astro:page-load", update)
  query.addEventListener("change", update)
</script>

<script>
  const initialize = () => {
    for (const root of document.querySelectorAll<HTMLElement>(
      "[data-drawer-demo]"
    )) {
      if (root.hasAttribute("data-bound")) continue
      root.setAttribute("data-bound", "")
      root.addEventListener("drawer:change", (event) => {
        const { open, originalEvent } = (event as CustomEvent).detail
        const target = originalEvent?.target
        const confirm =
          target instanceof Element
            ? target.closest("[data-drawer-demo-confirm]")
            : null
        if (open || !confirm) return
        const selected = confirm
          .closest('[data-slot="drawer-popup"]')
          ?.querySelector<HTMLElement>(
            '[data-slot="radio-group-item"][aria-checked="true"]'
          )
        if (!selected) return
        root.querySelector('[data-slot="toast"]')?.dispatchEvent(
          new CustomEvent("toast:show", {
            detail: {
              title: "Delivery time confirmed",
              description: selected.dataset.label,
            },
          })
        )
      })
    }
  }

  initialize()
  document.addEventListener("astro:page-load", initialize)
</script>
```

## Installation

```bash
npx shadcn@latest add @fulldev/drawer
```

## Usage

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

```astro
<Drawer swipeDirection="down">
  <DrawerTrigger>Open</DrawerTrigger>
  <DrawerContent showSwipeHandle>
    <DrawerHeader>
      <DrawerTitle>Drawer title</DrawerTitle>
      <DrawerDescription>Drawer description</DrawerDescription>
    </DrawerHeader>
    <div class="p-4">Drawer content</div>
    <DrawerFooter>
      <DrawerClose variant="outline">Close</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>
```

`DrawerTrigger` and `DrawerClose` render a `Button` and take its props.

## Composition

`DrawerContent` renders the portal, backdrop, viewport, popup and inner content
wrapper. Use the lower-level exports to customize that structure.

```text
Drawer
├── DrawerTrigger
└── DrawerContent
    ├── DrawerHeader
    │   ├── DrawerTitle
    │   └── DrawerDescription
    ├── content
    └── DrawerFooter
        └── DrawerClose
```

## Custom Sizes

A vertical drawer sizes itself to its content, capped at `calc(100dvh - 3rem)`.
A side drawer spans `75%` of the viewport width, up to `24rem`. Override with
`h-*`, `max-h-*`, `w-*` or `max-w-*` on `DrawerContent`. To make a region
scrollable, make it a flex item: `<div class="flex-1 overflow-y-auto p-4">`.

The popup exposes `data-swipe-direction`, so scope styles to a side with
`data-[swipe-direction=down]:` on `DrawerContent` or
`group-data-[swipe-direction=down]/drawer-popup:` on its descendants. It does not
set `data-swipe-axis`.

## Position

Use `swipeDirection` on `Drawer` to set the side: `up`, `right`, `down`
(default) or `left`.

```astro title="src/components/examples/drawer/drawer-sides.astro"
---
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
---

<Drawer swipeDirection="left">
  <DrawerTrigger 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>
    </div>
    <DrawerFooter>
      <DrawerClose>Close</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>
```

## Swipe Handle

Use `showSwipeHandle` on `DrawerContent` to render a swipe handle.

```astro title="src/components/examples/drawer/drawer-swipe-handle.astro"
---
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
---

<Drawer>
  <DrawerTrigger variant="secondary">Open Drawer</DrawerTrigger>
  <DrawerContent showSwipeHandle>
    <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-direction=down]/drawer-popup:h-80 group-data-[swipe-direction=down]/drawer-popup:w-full"></div>
    </div>
    <DrawerFooter>
      <DrawerClose>Close</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>
```

## Nested

Place a complete `Drawer` inside another drawer's content. Parent drawers stay
mounted behind the frontmost drawer, and Escape closes only the front one.

```astro title="src/components/examples/drawer/drawer-nested.astro"
---
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

const swipeDirection = "right"
const placeholder =
  "bg-muted group-data-[swipe-direction=right]/drawer-popup:size-full group-data-[swipe-direction=down]/drawer-popup:aspect-video group-data-[swipe-direction=down]/drawer-popup:w-full"
---

<Drawer swipeDirection={swipeDirection} data-drawer-responsive>
  <DrawerTrigger variant="secondary">Open Drawer</DrawerTrigger>
  <DrawerContent showSwipeHandle>
    <DrawerHeader>
      <DrawerTitle>Drawer</DrawerTitle>
      <DrawerDescription>
        Open another drawer from the same direction.
      </DrawerDescription>
    </DrawerHeader>
    <div class="flex-1 p-4">
      <div class={placeholder}></div>
    </div>
    <DrawerFooter>
      <Drawer swipeDirection={swipeDirection} data-drawer-responsive>
        <DrawerTrigger variant="outline">Open Nested Drawer</DrawerTrigger>
        <DrawerContent showSwipeHandle>
          <DrawerHeader>
            <DrawerTitle>Nested Drawer</DrawerTitle>
            <DrawerDescription>
              The parent drawer stays mounted behind this one.
            </DrawerDescription>
          </DrawerHeader>
          <div class="flex-1 p-4">
            <div class={placeholder}></div>
          </div>
          <DrawerFooter>
            <Drawer swipeDirection={swipeDirection} data-drawer-responsive>
              <DrawerTrigger variant="outline">Open Third Drawer</DrawerTrigger>
              <DrawerContent showSwipeHandle>
                <DrawerHeader>
                  <DrawerTitle>Third Drawer</DrawerTitle>
                  <DrawerDescription>
                    Two drawers are stacked behind this one.
                  </DrawerDescription>
                </DrawerHeader>
                <div class="flex-1 p-4">
                  <div class={placeholder}></div>
                </div>
                <DrawerFooter>
                  <Drawer
                    swipeDirection={swipeDirection}
                    data-drawer-responsive
                  >
                    <DrawerTrigger variant="outline">
                      Open Fourth Drawer
                    </DrawerTrigger>
                    <DrawerContent showSwipeHandle>
                      <DrawerHeader>
                        <DrawerTitle>Fourth Drawer</DrawerTitle>
                        <DrawerDescription>
                          This is the frontmost drawer in the stack.
                        </DrawerDescription>
                      </DrawerHeader>
                      <div class="flex-1 p-4">
                        <div class={placeholder}></div>
                      </div>
                      <DrawerFooter>
                        <DrawerClose variant="outline">Close</DrawerClose>
                      </DrawerFooter>
                    </DrawerContent>
                  </Drawer>
                  <DrawerClose variant="outline">Close</DrawerClose>
                </DrawerFooter>
              </DrawerContent>
            </Drawer>
            <DrawerClose variant="outline">Close</DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
      <DrawerClose variant="outline">Close</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>

<script>
  import { createDrawer } from "@data-slot/drawer"

  // Like useIsMobile: bottom drawers with a swipe handle below 768px.
  const query = matchMedia("(max-width: 767px)")
  const triggerOf = (root: HTMLElement) =>
    [...root.querySelectorAll('[data-slot="drawer-trigger"]')].find(
      (element) => element.closest('[data-slot="drawer"]') === root
    )
  const update = () => {
    const direction = query.matches ? "down" : "right"
    const roots = [
      ...document.querySelectorAll<HTMLElement>("[data-drawer-responsive]"),
    ]
    for (const root of roots) {
      // An open popup lives in the body, outside its root.
      const popup = document.getElementById(
        triggerOf(root)?.getAttribute("aria-controls") ?? ""
      )
      for (const parent of [root, popup])
        parent
          ?.querySelectorAll('[data-slot="drawer-handle"]')
          .forEach((handle) => handle.toggleAttribute("hidden", !query.matches))
    }
    // Data Slot reads the direction when it binds, and restores it when
    // destroyed, so rebind. Open drawers stay open: nested ones unbind first
    // and bind last.
    const stale = roots.filter(
      (root) => root.dataset.swipeDirection !== direction
    )
    const open = stale.map((root) => createDrawer(root).isOpen)
    for (const root of [...stale].reverse()) createDrawer(root).destroy()
    stale.forEach((root, index) => {
      root.dataset.swipeDirection = direction
      const drawer = createDrawer(root, { open: open[index] })
      // Reselect the trigger, which gets aria-expanded back.
      const trigger = triggerOf(root)
      if (open[index] && trigger?.id) drawer.open(trigger.id)
    })
  }

  update()
  document.addEventListener("astro:page-load", update)
  query.addEventListener("change", update)
</script>
```

## Non Modal

Set `modal={false}` and `showOverlay={false}` on `DrawerContent` to keep the
rest of the page interactive. Add `disablePointerDismissal` to keep the drawer
open on outside presses. `modal="trap-focus"` keeps focus inside the drawer
without making the page inert.

```astro title="src/components/examples/drawer/drawer-non-modal.astro"
---
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
---

<Drawer modal={false} disablePointerDismissal swipeDirection="right">
  <DrawerTrigger variant="outline">Non Modal</DrawerTrigger>
  <DrawerContent showOverlay={false}>
    <DrawerHeader>
      <DrawerTitle>Non Modal Drawer</DrawerTitle>
    </DrawerHeader>
    <div class="flex-1 p-4">
      <div class="rounded-2xl bg-muted group-data-[swipe-direction=right]/drawer-popup:size-full"></div>
    </div>
    <DrawerFooter>
      <DrawerClose>Close</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>
```

## Snap Points

The official example moves between two snap points. Data Slot supports one
`snapPoint` (a fraction of the viewport, a pixel number, or a `px` or `rem`
string), so this example is in the [React Drawer](/react/components/drawer/)
docs only.

## Responsive

Render a `Dialog` from `md` up and a `Drawer` below it, with `hidden md:block`
and `md:hidden` on the roots.

```astro title="src/components/examples/drawer/drawer-dialog.astro"
---
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
---

<!-- A Dialog from 768px (md), a Drawer below it. -->
<Dialog class="hidden md:block">
  <DialogTrigger 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>
    <form class="grid items-start gap-6">
      <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>
  </DialogContent>
</Dialog>

<Drawer class="md:hidden">
  <DrawerTrigger variant="outline">Edit Profile</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader class="text-left">
      <DrawerTitle>Edit profile</DrawerTitle>
      <DrawerDescription>
        Make changes to your profile here. Click save when you&apos;re done.
      </DrawerDescription>
    </DrawerHeader>
    <form class="grid items-start gap-6 p-4">
      <div class="grid gap-3">
        <Label for="email-drawer">Email</Label>
        <Input type="email" id="email-drawer" value="shadcn@example.com" />
      </div>
      <div class="grid gap-3">
        <Label for="username-drawer">Username</Label>
        <Input id="username-drawer" value="@shadcn" />
      </div>
      <Button type="submit">Save changes</Button>
    </form>
  </DrawerContent>
</Drawer>
```

## Events

Use the `drawer:open`, `drawer:close`, `drawer:toggle` and `drawer:set` DOM
events for imperative changes. The root emits cancelable `drawer:beforechange`
and `drawer:beforesnapchange` events, followed by `drawer:change` (with
`reason` and `originalEvent`), `drawer:change-complete` and `drawer:snapchange`.

```js
const drawer = document.querySelector('[data-slot="drawer"]')

drawer?.dispatchEvent(
  new CustomEvent("drawer:set", {
    detail: { open: true, snapPoint: "24rem" },
  })
)
```

## API Reference

Source: [`src/components/ui/drawer`](https://github.com/fulldotdev/ui/tree/main/src/components/ui/drawer)

| `Drawer` prop             | Type                                  | Default  |
| ------------------------- | ------------------------------------- | -------- |
| `swipeDirection`          | `"up" \| "right" \| "down" \| "left"` | `"down"` |
| `modal`                   | `boolean \| "trap-focus"`             | `true`   |
| `disablePointerDismissal` | `boolean`                             | `false`  |
| `closeOnEscape`           | `boolean`                             | `true`   |
| `defaultOpen`             | `boolean`                             | `false`  |
| `snapPoint`               | `number \| string \| null`            | `null`   |
| `defaultSnapPoint`        | `number \| string \| null`            | `null`   |
| `triggerId`               | `string \| null`                      |          |
| `defaultTriggerId`        | `string \| null`                      |          |

| `DrawerContent` prop | Type                | Default |
| -------------------- | ------------------- | ------- |
| `showOverlay`        | `boolean`           | `true`  |
| `showSwipeHandle`    | `boolean`           | `false` |
| `container`          | `string`            |         |
| `keepMounted`        | `boolean`           |         |
| `initialFocus`       | `boolean \| string` |         |
| `finalFocus`         | `boolean \| string` |         |
| `overlayClass`       | `string`            |         |
| `viewportClass`      | `string`            |         |

See the [Data Slot docs](https://github.com/bejamas/data-slot/blob/main/packages/drawer/README.md)
for controllers, focus management, detached triggers and keyboard-aware
viewports.

## Install what this page shows

```bash
npx shadcn@latest add @fulldev/drawer-examples
```
