# Drawer

A panel that slides in from an edge, most often the bottom, for a short task on a phone; it closes when it is dragged back.

- **Import:** `import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger } from "@booleanpress/ui/drawer"`
- **Also install:** `@base-ui/react`
- **APG Dialog (Modal):** <https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/>
- **Page:** <https://ui.booleanpress.com/components/drawer> · @booleanpress/ui 0.2.0

## Usage

`Drawer` is [Base UI's Drawer](https://base-ui.com/react/components/drawer) with shadcn's drawer parts and look. It needs the `@base-ui/react` peer package, which only products that import `@booleanpress/ui/drawer` install. From the bottom it carries a handle bar; dragging the drawer down past the threshold closes it.

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

export function Deliveries() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">View today’s deliveries</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Today’s deliveries</DrawerTitle>
          <DrawerDescription>Primary mailer.</DrawerDescription>
        </DrawerHeader>
        <DrawerFooter>
          <DrawerClose asChild>
            <Button variant="outline">Close</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

`direction` sets the edge: `bottom` (the default), `top`, `left` or `right`. Like Sheet's `side`, `left` and `right` are the reading direction's start and end, so in right-to-left `right` opens from the left edge; the drawer is dragged back towards its edge to close. `DrawerTrigger` and `DrawerClose` take `asChild`, as in shadcn, to render your `Button`. To open it from code, control it with `open` and `onOpenChange` and leave out the trigger. A drawer taller than 80 % of the window stops there: let a part of its content scroll with `min-h-0 flex-1 overflow-y-auto`.

For a task that should be a Dialog on a wide screen and a Drawer on a phone, render one or the other from a media query, with the same content inside; the **Responsive** example shows the pattern. On a desktop screen with no touch, prefer Dialog or Sheet: Drawer adds the drag gesture, which only touch screens need.

## Examples

### Basic

From the bottom, with the handle bar: drag it down, press Escape, click outside or press Close.

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

const RESULTS = [
  { label: "Delivered", value: "12,431" },
  { label: "Bounced", value: "37" },
  { label: "Complaints", value: "2" },
]

export default function DrawerBasic() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">View today’s deliveries</Button>
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Today’s deliveries</DrawerTitle>
            <DrawerDescription>Primary mailer, 4 October 2026. Drag the bar down to close.</DrawerDescription>
          </DrawerHeader>
          <dl className="grid grid-cols-3 gap-3 px-4.5">
            {RESULTS.map((result) => (
              <div key={result.label} className="rounded-lg border p-3 text-center">
                <dt className="text-xs/normal text-muted-foreground">{result.label}</dt>
                <dd className="text-xl font-semibold tabular-nums">{result.value}</dd>
              </div>
            ))}
          </dl>
          <DrawerFooter>
            <Button>Open the delivery log</Button>
            <DrawerClose asChild>
              <Button variant="outline">Close</Button>
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}
```

### Sides

`direction` opens it from the top, the start or the end edge; each closes by dragging back towards its edge.

```tsx
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@booleanpress/ui/drawer"

const SIDES = [
  { direction: "top", label: "Top", icon: ArrowDownIcon },
  { direction: "left", label: "Start", icon: ArrowRightIcon },
  { direction: "right", label: "End", icon: ArrowLeftIcon },
] as const

export default function DrawerSides() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      {SIDES.map(({ direction, label, icon: Icon }) => (
        <Drawer key={direction} direction={direction}>
          <DrawerTrigger asChild>
            <Button variant="outline">
              <Icon className="rtl:rotate-180" />
              {label}
            </Button>
          </DrawerTrigger>
          <DrawerContent>
            <DrawerHeader>
              <DrawerTitle>Notifications</DrawerTitle>
              <DrawerDescription>Three mailers need your attention.</DrawerDescription>
            </DrawerHeader>
            <ul className="flex flex-col gap-2 px-4.5 text-sm/normal">
              <li>Backup: the password was rejected.</li>
              <li>Marketing: 37 bounces since midnight.</li>
              <li>Receipts: the daily limit is 90 % used.</li>
            </ul>
            <DrawerFooter>
              <DrawerClose asChild>
                <Button variant="outline">Close</Button>
              </DrawerClose>
            </DrawerFooter>
          </DrawerContent>
        </Drawer>
      ))}
    </div>
  )
}
```

### Responsive

A Dialog from 768 px and a Drawer below, from one media query, with the same title, description and form.

```tsx
import { useSyncExternalStore } from "react"
import { Button } from "@booleanpress/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@booleanpress/ui/dialog"
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@booleanpress/ui/drawer"
import { Input } from "@booleanpress/ui/input"
import { Label } from "@booleanpress/ui/label"

const QUERY = "(min-width: 768px)"

function useWide() {
  return useSyncExternalStore(
    (onChange) => {
      const media = window.matchMedia(QUERY)
      media.addEventListener("change", onChange)
      return () => media.removeEventListener("change", onChange)
    },
    () => window.matchMedia(QUERY).matches,
    () => true
  )
}

function KeyForm() {
  return (
    <div className="flex flex-col gap-2 px-4.5 pb-4.5 md:px-0 md:pb-0">
      <Label htmlFor="key-name">Key name</Label>
      <Input id="key-name" defaultValue="Production server" />
      <Button className="mt-2">Create key</Button>
    </div>
  )
}

export default function DrawerResponsive() {
  const wide = useWide()
  const title = "Create an API key"
  const description = "The key is shown once, so copy it before you close this."

  if (wide) {
    return (
      <Dialog>
        <DialogTrigger asChild>
          <Button variant="outline">Create an API key</Button>
        </DialogTrigger>
        <DialogContent size="sm">
          <DialogHeader>
            <DialogTitle>{title}</DialogTitle>
            <DialogDescription>{description}</DialogDescription>
          </DialogHeader>
          <KeyForm />
        </DialogContent>
      </Dialog>
    )
  }
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">Create an API key</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>{title}</DrawerTitle>
          <DrawerDescription>{description}</DrawerDescription>
        </DrawerHeader>
        <KeyForm />
      </DrawerContent>
    </Drawer>
  )
}
```

### Scrollable content

A long list scrolls inside the drawer, which stops at 80 % of the window; the header and the footer stay.

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

const EVENTS = Array.from({ length: 30 }, (_, index) => ({
  id: 1040 + index,
  to: `customer${index + 1}@example.com`,
  status: index % 9 === 4 ? "Bounced" : "Delivered",
  time: `09:${String(index * 2).padStart(2, "0")}`,
}))

export default function DrawerScrollable() {
  return (
    <Drawer>
      <DrawerTrigger asChild>
        <Button variant="outline">Open the delivery log</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Delivery log</DrawerTitle>
          <DrawerDescription>30 messages sent this morning, 4 October 2026.</DrawerDescription>
        </DrawerHeader>
        <ul className="min-h-0 flex-1 divide-y overflow-y-auto px-4.5 text-sm/normal">
          {EVENTS.map((event) => (
            <li key={event.id} className="flex items-center justify-between gap-4 py-2">
              <span className="truncate">{event.to}</span>
              <span className="shrink-0 text-muted-foreground tabular-nums">
                {event.status} · {event.time}
              </span>
            </li>
          ))}
        </ul>
        <DrawerFooter>
          <DrawerClose asChild>
            <Button variant="outline">Close</Button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  )
}
```

## Accessibility

**Semantics.** The panel is a `div` with `role="dialog"`, named by `DrawerTitle` and described by `DrawerDescription`. While it is open, the rest of the page is inert and hidden from assistive technology, and the page does not scroll. The handle bar is decorative (`aria-hidden`).

**Labels.** Every drawer needs a `DrawerTitle`; keep one for screen readers with `className="sr-only"` if the design has no visible title. Dragging is never the only way to close: give the drawer a `DrawerClose` button, as every example does.

**Focus.** Opening moves focus into the drawer: to the panel, from which Tab reaches its first control. Tab and Shift+Tab stay inside. Closing returns focus to the trigger; pass `finalFocus` to `DrawerContent` to send it elsewhere, for example when the trigger is gone.

**Known limits.**

- One modal at a time: do not open a drawer from inside a Dialog or a Sheet.
- The drag gesture is a pointer feature; keyboard and screen-reader users close with Escape or the close button.
- Snap points, nested drawers and the indent effect of Base UI's Drawer are not part of this API yet; Base UI's own parts can be used for them.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Escape | Closes the drawer and returns focus to the trigger. With a select, menu or popover open inside it, closes that first. |
| Tab | Moves to the next focusable element inside the drawer, wrapping from the last to the first. |
| Shift + Tab | Moves to the previous focusable element inside the drawer, wrapping from the first to the last. |

## API

### Drawer

Renders Base UI Drawer.Root and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `actionsRef` | `RefObject<DrawerRootActions \| null>` |  | A ref to imperative actions. - `unmount`: Manually unmounts the drawer. Call this after any externally controlled closing animation finishes. - `close`: Closes the drawer imperatively when called. |
| `children` | `ReactNode \| PayloadChildRenderFunction<unknown>` |  | The content of the drawer. |
| `defaultOpen` | `boolean` | `false` | Whether the drawer is initially open. To render a controlled drawer, use the `open` prop instead. |
| `defaultSnapPoint` | `DrawerSnapPoint \| null` |  | The initial snap point value when uncontrolled. |
| `defaultTriggerId` | `string \| null` |  | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `defaultOpen` prop to create an initially open drawer. |
| `direction` | `"top" \| "bottom" \| "left" \| "right"` | `bottom` | The edge it opens from; `left` and `right` follow the reading direction. |
| `disablePointerDismissal` | `boolean` | `false` | Whether to prevent the drawer from closing on outside presses. For non-modal drawers, this also prevents the drawer from closing when focus moves outside of it. |
| `handle` | `DrawerHandle<unknown>` |  | A handle to associate the drawer with a trigger. If specified, allows detached triggers to control the drawer's open state. Can be created with the Drawer.createHandle() method. |
| `modal` | `boolean \| "trap-focus"` | `true` | Determines if the drawer enters a modal state when open. - `true`: user interaction is limited to just the drawer: focus is trapped, document page scroll is locked, and pointer interactions on outside elements are disabled. - `false`: user interaction with the rest of the document is allowed. - `'trap-focus'`: focus is trapped inside the drawer, but document page scroll is not locked and pointer interactions outside of it remain enabled. |
| `onOpenChange` | `((open: boolean, eventDetails: DrawerRootChangeEventDetails) => void)` |  | Event handler called when the drawer is opened or closed. |
| `onOpenChangeComplete` | `((open: boolean) => void)` |  | Event handler called after any animations complete when the drawer is opened or closed. |
| `onSnapPointChange` | `((snapPoint: DrawerSnapPoint \| null, eventDetails: DrawerRootSnapPointChangeEventDetails) => void)` |  | Callback fired when the snap point changes. |
| `open` | `boolean` |  | Whether the drawer is currently open. |
| `snapPoint` | `DrawerSnapPoint \| null` |  | The currently active snap point. Use with `onSnapPointChange` to control the snap point. |
| `snapPoints` | `DrawerSnapPoint[]` |  | Snap points used to position the drawer. Use numbers between 0 and 1 to represent fractions of the viewport height, numbers greater than 1 as pixel values, or strings in `px`/`rem` units (for example, `'148px'` or `'30rem'`). |
| `snapToSequentialPoints` | `boolean` | `false` | Disables velocity-based snap skipping so drag distance determines the next snap point. |
| `swipeDirection` | `"left" \| "right" \| "up" \| "down"` | `'down'` | The swipe direction used to dismiss the drawer. |
| `triggerId` | `string \| null` |  | ID of the trigger that the drawer is associated with. This is useful in conjunction with the `open` prop to create a controlled drawer. There's no need to specify this prop when the drawer is uncontrolled (that is, when the `open` prop is not set). |

### DrawerClose

Renders Base UI Drawer.Close and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with this part's behaviour merged onto it. |
| `className` | `string \| ((state: DrawerCloseState) => string)` |  | CSS class applied to the element, or a function that returns a class based on the component's state. |
| `nativeButton` | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerCloseState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerCloseState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

### DrawerContent

Renders Base UI Drawer.Popup and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` |  |  |
| `finalFocus` | `boolean \| RefObject<HTMLElement \| null> \| ((closeType: InteractionType) => boolean \| void \| HTMLElement \| null)` |  | Determines the element to focus when the drawer is closed. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`).   Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing. |
| `initialFocus` | `boolean \| RefObject<HTMLElement \| null> \| ((openType: InteractionType) => boolean \| void \| HTMLElement \| null)` |  | Determines the element to focus when the drawer is opened. - `false`: Do not move focus. - `true`: Move focus based on the default behavior (first tabbable element or popup). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`).   Return an element to focus, `true` to use the default behavior, or `false`/`undefined` to do nothing. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerPopupState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerPopupState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

### DrawerDescription

Renders Base UI Drawer.Description and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` |  |  |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerDescriptionState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerDescriptionState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

### DrawerFooter

Renders a `div` and passes it every other prop.

### DrawerHeader

Renders a `div` and passes it every other prop.

### DrawerOverlay

Renders Base UI Drawer.Backdrop and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` |  |  |
| `forceRender` | `boolean` | `false` | Whether the backdrop is forced to render even when nested. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerBackdropState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerBackdropState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

### DrawerPortal

Renders Base UI Drawer.Portal and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string \| ((state: DrawerPortalState) => string)` |  | CSS class applied to the element, or a function that returns a class based on the component's state. |
| `container` | `HTMLElement \| ShadowRoot \| RefObject<HTMLElement \| ShadowRoot \| null> \| null` |  | A parent element to render the portal element into. |
| `keepMounted` | `boolean` | `false` | Whether to keep the portal mounted in the DOM while the popup is hidden. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerPortalState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerPortalState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

### DrawerTitle

Renders Base UI Drawer.Title and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` |  |  |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerTitleState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerTitleState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

### DrawerTrigger

Renders Base UI Drawer.Trigger and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with this part's behaviour merged onto it. |
| `className` | `string \| ((state: DrawerTriggerState) => string)` |  | CSS class applied to the element, or a function that returns a class based on the component's state. |
| `handle` | `DrawerHandle<unknown>` |  | A handle to associate the trigger with a drawer. Can be created with the Drawer.createHandle() method. |
| `id` | `string` |  | ID of the trigger. In addition to being forwarded to the rendered element, it is also used to specify the active trigger for drawers in controlled mode (with the Drawer.Root `triggerId` prop). |
| `nativeButton` | `boolean` | `true` | Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`). |
| `payload` | `unknown` |  | A payload to pass to the drawer when it is opened. |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DrawerTriggerState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `style` | `CSSProperties \| ((state: DrawerTriggerState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |

**Also exported:** `DrawerDirection`, a TypeScript type.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="drawer-close"` (DrawerClose), `data-slot="drawer-viewport"` (DrawerContent), `data-slot="drawer-description"` (DrawerDescription), `data-slot="drawer-footer"` (DrawerFooter), `data-slot="drawer-header"` (DrawerHeader), `data-slot="drawer-overlay"` (DrawerOverlay), `data-slot="drawer-portal"` (DrawerPortal), `data-slot="drawer-title"` (DrawerTitle), `data-slot="drawer-trigger"` (DrawerTrigger), and `data-side`.

## Theming

The panel is `--card` and `--card-foreground` with the `--border` edge on its open side, a 12 px radius on the corners away from its edge, and the modal shadow. The backdrop is `--mask`, and it fades as the drawer is dragged away. The handle bar is `--muted`. Enter and exit motion is the modal motion, set once in `theme.css`.

| Token | Used for |
| --- | --- |
| `--card` | background |
| `--card-foreground` | text |
| `--foreground` | text |
| `--mask` | background |
| `--muted` | background |
| `--muted-foreground` | text |
