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
divwithrole="dialog", named byDrawerTitleand described byDrawerDescription. 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 withclassName="sr-only"if the design has no visible title. Dragging is never the only way to close: give the drawer aDrawerClosebutton, 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
finalFocustoDrawerContentto 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. |
| ShiftTab | 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.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--card | background |
--card-foreground | text |
--foreground | text |
--mask | background |
--muted | background |
--muted-foreground | text |