Skip to the content

ComponentsOverlay

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: pnpm add @base-ui/react

Usage

Drawer is Base UI's 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.

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.

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.

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.

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.

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

Keyboard
KeyBehaviour
EscapeCloses the drawer and returns focus to the trigger. With a select, menu or popover open inside it, closes that first.
TabMoves to the next focusable element inside the drawer, wrapping from the last to the first.
ShiftTabMoves 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.

Drawer props
PropTypeDefaultDescription
actionsRefRefObject<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.
childrenReactNode | PayloadChildRenderFunction<unknown>The content of the drawer.
defaultOpenbooleanfalseWhether the drawer is initially open. To render a controlled drawer, use the open prop instead.
defaultSnapPointDrawerSnapPoint | nullThe initial snap point value when uncontrolled.
defaultTriggerIdstring | nullID 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"bottomThe edge it opens from; left and right follow the reading direction.
disablePointerDismissalbooleanfalseWhether 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.
handleDrawerHandle<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.
modalboolean | "trap-focus"trueDetermines 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.
openbooleanWhether the drawer is currently open.
snapPointDrawerSnapPoint | nullThe currently active snap point. Use with onSnapPointChange to control the snap point.
snapPointsDrawerSnapPoint[]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').
snapToSequentialPointsbooleanfalseDisables 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.
triggerIdstring | nullID 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.

DrawerClose props
PropTypeDefaultDescription
asChildbooleanRender the child element instead, with this part's behaviour merged onto it.
classNamestring | ((state: DrawerCloseState) => string)CSS class applied to the element, or a function that returns a class based on the component's state.
nativeButtonbooleantrueWhether 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>).
renderReactElement<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.
styleCSSProperties | ((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.

DrawerContent props
PropTypeDefaultDescription
classNamestring
finalFocusboolean | 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.
initialFocusboolean | 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.
renderReactElement<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.
styleCSSProperties | ((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.

DrawerDescription props
PropTypeDefaultDescription
classNamestring
renderReactElement<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.
styleCSSProperties | ((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.

DrawerOverlay props
PropTypeDefaultDescription
classNamestring
forceRenderbooleanfalseWhether the backdrop is forced to render even when nested.
renderReactElement<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.
styleCSSProperties | ((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.

DrawerPortal props
PropTypeDefaultDescription
classNamestring | ((state: DrawerPortalState) => string)CSS class applied to the element, or a function that returns a class based on the component's state.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullA parent element to render the portal element into.
keepMountedbooleanfalseWhether to keep the portal mounted in the DOM while the popup is hidden.
renderReactElement<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.
styleCSSProperties | ((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.

DrawerTitle props
PropTypeDefaultDescription
classNamestring
renderReactElement<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.
styleCSSProperties | ((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.

DrawerTrigger props
PropTypeDefaultDescription
asChildbooleanRender the child element instead, with this part's behaviour merged onto it.
classNamestring | ((state: DrawerTriggerState) => string)CSS class applied to the element, or a function that returns a class based on the component's state.
handleDrawerHandle<unknown>A handle to associate the trigger with a drawer. Can be created with the Drawer.createHandle() method.
idstringID 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).
nativeButtonbooleantrueWhether 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>).
payloadunknownA payload to pass to the drawer when it is opened.
renderReactElement<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.
styleCSSProperties | ((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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--cardbackground
--card-foregroundtext
--foregroundtext
--maskbackground
--mutedbackground
--muted-foregroundtext