---
title: Drawer
description: A panel that slides in from an edge of the screen and can be swiped away.
icon: panel-left
sidebar:
  badge: Updated
search:
  keywords: [bottom sheet, slide-over, action sheet, swipe]
related:
  - /sheet
  - /dialog
  - /credenza
mode: wide
---

<PoweredBy
  packages={[
    { name: 'Base UI', url: 'https://base-ui.com/react/components/drawer' },
    { name: 'Shadcn UI', url: 'https://ui.shadcn.com/docs/components/drawer' },
  ]}
/>

### Installation

```package-install
npx shadcn@latest add @redpanda/drawer
```

<ComponentPreview name="drawer-demo" description="A bottom drawer with a grab handle." />

## When to use

Use this decision tree to determine when to use the Drawer component:

```mermaid
graph TD;
    A[❓ Need a panel over the page?] --> B{What device target?};
    B --> C[✅ Mobile-first design];
    B --> D[ℹ️ Desktop + mobile];
    B --> E[⚠️ Desktop only];

    C --> F[❓ Content type?];
    D --> G[✅ Use responsive Dialog + Drawer, or Credenza];
    E --> H[⚠️ Use Sheet or Dialog instead];

    F --> I[✅ Forms/actions];
    F --> J[✅ Settings/options];
    F --> K[✅ Navigation menus];
    F --> L[⚠️ Simple confirmations];

    I --> M[❓ Should the page stay usable?];
    J --> M;
    K --> M;
    L --> N[⚠️ Use AlertDialog instead];

    M --> O["✅ Default: modal, swipe to dismiss"];
    M --> P["ℹ️ modal={false}: non-modal panel"];

    O --> Q[❓ Several heights?];
    P --> Q;
    G --> Q;

    Q --> R[✅ Set snapPoints on a bottom drawer];
    Q --> S[ℹ️ Height follows the content];

    classDef recommended fill:#4ade80,stroke:#22c55e,stroke-width:2px,color:#000;
    classDef discouraged fill:#fbbf24,stroke:#f59e0b,stroke-width:2px,color:#000;
    classDef decision fill:#60a5fa,stroke:#3b82f6,stroke-width:2px,color:#fff;
    classDef neutral fill:#9ca3af,stroke:#6b7280,stroke-width:2px,color:#fff;

    class C,G,I,J,K,O,R recommended;
    class E,H,L,N discouraged;
    class A,B,F,M,Q decision;
    class D,P,S neutral;
```

A drawer and a [Sheet](/docs/sheet) both slide in from an edge. Reach for the drawer when people should be able to
swipe it away, or when it needs snap points; a sheet is a positioned dialog with no gestures.

## Usage

```tsx
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/redpanda-ui/drawer';
```

```tsx
<Drawer>
  <DrawerTrigger render={<Button variant="outline">Open</Button>} />
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Are you absolutely sure?</DrawerTitle>
      <DrawerDescription>This action cannot be undone.</DrawerDescription>
    </DrawerHeader>
    <DrawerFooter>
      <Button>Delete topic</Button>
      <DrawerClose render={<Button variant="outline">Cancel</Button>} />
    </DrawerFooter>
  </DrawerContent>
</Drawer>
```

`DrawerTrigger` and `DrawerClose` take Base UI's `render` prop to become your own button, like every other
trigger in the registry.

## Anatomy

```
<Drawer> (Base UI Drawer.Root: open state, direction, snap points)
├── <DrawerTrigger> (button)
└── <DrawerContent> (portal + overlay + viewport + panel)
    ├── <DrawerOverlay> (fades with the swipe; hidden when modal={false})
    └── panel (role="dialog", data-direction, data-swipe-direction)
        ├── grab handle (bottom drawers only; a mouse drags the drawer by it)
        ├── <DrawerHeader>
        │   ├── <DrawerTitle> (h2, names the dialog)
        │   └── <DrawerDescription> (p)
        ├── your content
        └── <DrawerFooter> (pinned to the bottom with mt-auto)
            └── <DrawerClose>
```

## Examples

### Sides

Use the `direction` prop to set the edge the drawer opens from: `top`, `right`, `bottom` (the default) or `left`.

<ComponentPreview name="drawer-directions-demo" description="Drawers opening from each of the four sides." />

`direction` also sets the swipe that dismisses the drawer. It maps to Base UI's `swipeDirection`, which names the
swipe rather than the edge:

| `direction`        | Sits on     | Dismissed by swiping (`swipeDirection`) |
| ------------------ | ----------- | --------------------------------------- |
| `bottom` (default) | bottom edge | `down`                                  |
| `top`              | top edge    | `up`                                    |
| `left`             | left edge   | `left`                                  |
| `right`            | right edge  | `right`                                 |

### Half-width

Use `size="half"` on `DrawerContent` for a side drawer that spans half the viewport width on desktop (full width on
mobile). The default `size="md"` is `w-3/4 sm:max-w-sm`. `size` only applies to `left` and `right` drawers: a `top`
or `bottom` drawer spans the viewport and takes its height from its content.

<ComponentPreview name="drawer-half-demo" description="Left and right drawers at half viewport width." />

A width in `className` replaces the default, no `!` needed:

```tsx
<DrawerContent className="w-full sm:w-[560px] sm:max-w-none">{/* ... */}</DrawerContent>
```

### Snap points

Pass `snapPoints` to a bottom drawer to let it rest at preset heights. A number from 0 to 1 is a fraction of
the viewport height, a number above 1 is pixels, and strings take `px` or `rem`. Control the active one with
`snapPoint` and `onSnapPointChange`. The drawer is translated down to each snap point, so give it the full height
(`className="h-full max-h-full"`) for the lower ones to have something to show.

<ComponentPreview
  name="drawer-snap-points-demo"
  description="A bottom drawer that snaps between 200px, 450px and full height."
/>

```tsx
const snapPoints = ['200px', '450px', 1];
const [snap, setSnap] = useState<string | number | null>(snapPoints[0]);

<Drawer onSnapPointChange={setSnap} snapPoint={snap} snapPoints={snapPoints} snapToSequentialPoints>
  <DrawerContent className="h-full max-h-full">{/* ... */}</DrawerContent>
</Drawer>;
```

A fast swipe can skip snap points, or close the drawer from any of them. `snapToSequentialPoints` stops that: a
swipe moves one snap point at a time.

:::warning
Snap points are vertical only. A `left` or `right` drawer ignores `snapPoints` and opens fully, where vaul let side
drawers snap to widths too.
:::

### Non-modal

`modal={false}` leaves the page usable: no overlay, no focus trap, no scroll lock, and clicking the page leaves the
drawer open. It still closes from its trigger, `DrawerClose`, Escape and a swipe. To close it on an outside click as
well, pass `disablePointerDismissal={false}`.

<ComponentPreview name="drawer-non-modal-demo" description="A non-modal inspector panel next to an interactive page." />

### Action sheet

Render each action as a `DrawerClose` so choosing one dismisses the sheet.

<ComponentPreview
  name="drawer-action-sheet-demo"
  description="A bottom action sheet with a separate destructive action."
/>

```tsx
<DrawerClose
  render={
    <Button onClick={archiveDocument} variant="unstyled">
      Archive
    </Button>
  }
/>
```

### Responsive dialog

You can combine the `Dialog` and `Drawer` components to create a responsive dialog. This renders a `Dialog` component
on desktop and a `Drawer` on mobile. [Credenza](/docs/credenza) packages the same pattern.

<ComponentPreview name="drawer-dialog-demo" />

## Swipe gestures

Touch swipes start anywhere on the drawer. A mouse only drags the drawer from outside its content (the grab handle,
or an empty part of the panel), so text in the drawer stays selectable.

A vertical scroller inside a bottom drawer scrolls first and hands over to the swipe once it reaches the top, and a
natively scrolling element on the other axis, such as an `overflow-x-auto` table, keeps its own scroll.

Something that handles its own drag in JavaScript, such as a carousel, a slider or a map, needs
`data-base-ui-swipe-ignore` to keep its drags from moving the drawer:

| Value                           | Effect                                                                                               |
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `data-base-ui-swipe-ignore`     | The element never starts a swipe, from touch or mouse.                                               |
| `data-base-ui-swipe-ignore="x"` | Horizontal touch drags go to the element; a vertical touch drag still swipes a bottom or top drawer. |
| `data-base-ui-swipe-ignore="y"` | Vertical touch drags go to the element; a horizontal touch drag still swipes a left or right drawer. |

Use `"x"` for horizontally scrolling content in a bottom drawer, so people can still swipe the drawer down from it:

```tsx
<Drawer>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Recent clusters</DrawerTitle>
    </DrawerHeader>
    {/* Embla handles horizontal drags itself; vertical drags still dismiss the drawer. */}
    <Carousel data-base-ui-swipe-ignore="x">
      <CarouselContent>{/* ... */}</CarouselContent>
    </Carousel>
  </DrawerContent>
</Drawer>
```

`"x"` and `"y"` only change touch input. Any value opts the element out of mouse drags, which press and capture the
pointer before the axis is known.

## Props

### Base UI

<div className="flex flex-row gap-x-3">
  <ExternalLink href="https://base-ui.com/react/components/drawer" text="Docs" />
  <ExternalLink href="https://base-ui.com/react/components/drawer#api-reference" text="API Reference" />
</div>

`Drawer` takes Base UI's `Drawer.Root` props (`open`, `defaultOpen`, `onOpenChange`, `modal`, `snapPoints`,
`snapPoint`, `onSnapPointChange`, `snapToSequentialPoints`, `disablePointerDismissal`, `onOpenChangeComplete`,
`actionsRef`, `handle`) except `swipeDirection`, which comes from `direction`. `DrawerContent` takes `Drawer.Popup`
props, including `initialFocus` and `finalFocus`.

### Drawer

| Prop | Type | Default | Description |
| - | - | - | - |
| `direction?` | `'top' \| 'right' \| 'bottom' \| 'left'` | `'bottom'` | The edge the drawer slides in from. Also sets the swipe that dismisses it. |
| `container?` | `HTMLElement \| null` | - | Where the drawer portals to. A container on DrawerContent wins. |
| `modal?` | `boolean \| 'trap-focus'` | `true` | false leaves the page usable: no overlay, focus trap or scroll lock, and outside clicks leave the drawer open. |
| `dismissible?` | `boolean` | `true` | When false, Escape, outside clicks, focus leaving the drawer and swipes no longer close it. DrawerClose, the trigger and a controlled open still do. |
| `disablePointerDismissal?` | `boolean` | - | Keep the drawer open on outside clicks. Defaults to true when modal is false or dismissible is false. |
| `onOpenChange?` | `(open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => void` | - | Called with the next open state and the event details (reason, cancel()). |

### DrawerContent

| Prop | Type | Default | Description |
| - | - | - | - |
| `size?` | `'md' \| 'half'` | `'md'` | Width of a left or right drawer. Does not apply to top or bottom drawers. |
| `showOverlay?` | `boolean` | `true` | Render the overlay behind a modal drawer. A non-modal drawer never has one. |
| `container?` | `HTMLElement` | - | Render inside this element instead of the document body. The drawer is then positioned within it. |
| `initialFocus?` | `boolean \| RefObject<HTMLElement \| null> \| ((openType) => boolean \| HTMLElement \| null \| void)` | - | Where focus goes on open. Defaults to the drawer panel; false leaves focus where it was. |
| `finalFocus?` | `boolean \| RefObject<HTMLElement \| null> \| ((closeType) => boolean \| HTMLElement \| null \| void)` | - | Where focus goes on close. Defaults to the trigger. |
| `testId?` | `string` | - | Sets data-testid on the drawer panel. |

### Styling hooks

| Hook                                                                                   | On             | Use                                                                          |
| -------------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------- |
| `data-direction="top \| right \| bottom \| left"`                                      | panel          | Style by edge: `group-data-[direction=bottom]/drawer-content:` from a child. |
| `data-swipe-direction`, `data-swiping`, `data-swipe-dismiss`                           | panel          | Style during and after a swipe.                                              |
| `data-starting-style`, `data-ending-style`, `data-open`, `data-closed`                 | panel, overlay | Enter and exit transitions.                                                  |
| `data-expanded`                                                                        | panel          | The drawer is at its full-height snap point.                                 |
| `--drawer-swipe-movement-x`, `--drawer-swipe-movement-y`, `--drawer-snap-point-offset` | panel          | The drag and snap offsets the panel's `transform` is built from.             |
| `--drawer-swipe-progress`                                                              | overlay        | 0 to 1 as the drawer is swiped away.                                         |

## Migrating from vaul

The drawer was built on vaul. It is now Base UI's Drawer, which brings velocity-aware swipe release (including 120 Hz
input), the Android back gesture, axis-aware `data-base-ui-swipe-ignore`, and opt-in virtual keyboard handling through
`Drawer.VirtualKeyboardProvider`.

| vaul                                                                                                                                                                                                               | Now                                                                                                                |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `direction`                                                                                                                                                                                                        | Unchanged.                                                                                                         |
| `<DrawerTrigger asChild><Button /></DrawerTrigger>` (and `DrawerClose`)                                                                                                                                            | `<DrawerTrigger render={<Button />} />`                                                                            |
| `onOpenAutoFocus={(event) => event.preventDefault()}`                                                                                                                                                              | `initialFocus={false}`                                                                                             |
| `activeSnapPoint` / `setActiveSnapPoint` / `snapToSequentialPoint`                                                                                                                                                 | `snapPoint` / `onSnapPointChange` / `snapToSequentialPoints` (the old names were removed in this breaking release) |
| `snapPoints` on a `left` or `right` drawer                                                                                                                                                                         | Not supported: the drawer opens fully. Snap points are vertical only.                                              |
| `container` on `Drawer`                                                                                                                                                                                            | Unchanged. A `container` on `DrawerContent` wins.                                                                  |
| `modal={false}`                                                                                                                                                                                                    | Unchanged: no overlay, and outside clicks leave it open.                                                           |
| `dismissible={false}`                                                                                                                                                                                              | Unchanged, except `DrawerClose` now closes the drawer.                                                             |
| `onOpenChange(open)`                                                                                                                                                                                               | Also receives `eventDetails`; a one-argument handler still works.                                                  |
| `onAnimationEnd`                                                                                                                                                                                                   | `onOpenChangeComplete`                                                                                             |
| `onClose`                                                                                                                                                                                                          | `onOpenChange` with `open === false`                                                                               |
| `repositionInputs`                                                                                                                                                                                                 | Wrap the content in `Drawer.VirtualKeyboardProvider` from `@base-ui/react/drawer`.                                 |
| `shouldScaleBackground` and `data-vaul-drawer-wrapper`                                                                                                                                                             | `Drawer.Provider`, `Drawer.Indent` and `Drawer.IndentBackground` from `@base-ui/react/drawer`.                     |
| `handleOnly`, `closeThreshold`, `scrollLockTimeout`, `fadeFromIndex`, `fixed`, `noBodyStyles`, `setBackgroundColorOnScale`, `disablePreventScroll`, `preventScrollRestoration`, `autoFocus`, `onDrag`, `onRelease` | Removed.                                                                                                           |
| `data-[vaul-drawer-direction=bottom]:…`                                                                                                                                                                            | `data-[direction=bottom]:…`                                                                                        |
| `data-[state=open]` / `data-[state=closed]`                                                                                                                                                                        | `data-[open]` / `data-[closed]`                                                                                    |

## Keyboard interactions

| Key                                  | Action                                                                   |
| ------------------------------------ | ------------------------------------------------------------------------ |
| `Enter` / `Space` on `DrawerTrigger` | Opens the drawer and moves focus to its panel.                           |
| `Tab` / `Shift+Tab`                  | Moves through the drawer. Focus is trapped while a modal drawer is open. |
| `Escape`                             | Closes the drawer and returns focus to the trigger.                      |

## Accessibility

- Always include a `DrawerTitle` (visually hidden is fine): it names the dialog.
- Add a `DrawerDescription` when the purpose isn't obvious from the title.
- A modal drawer sets `aria-modal="true"` and makes the page behind it inert. A non-modal drawer leaves the page
  reachable.
- Swiping is never the only way out: keep a `DrawerClose` in the drawer, or rely on Escape and the overlay.
- Under `prefers-reduced-motion: reduce` the drawer opens and closes without sliding.

## Credits

- We use [Base UI](https://base-ui.com/react/components/drawer) for the drawer component.
- We take our inspiration from [Shadcn UI](https://ui.shadcn.com/docs/components/drawer) for the drawer style.
