Drawer
An accessible slide-in panel built on the Base UI Drawer primitive. Compound component pattern with Header, Body, Footer, Title, Description, and Item rows. Swipe-to-dismiss on touch and pointer, popover surface, four sides, five sizes, five corner-rounding options, edge or floating inset presentation, and a full modal spectrum (modal, focus-trap-only, non-modal). LTR and RTL support.
Playground
Installation
pnpm add @tessinaui/uiUsage
import {
Drawer,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
DrawerBody,
DrawerFooter,
} from "@tessinaui/ui";<Drawer>
<DrawerTrigger className="...">Open</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Panel title</DrawerTitle>
<DrawerDescription>Supporting description text.</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<p>Content goes here.</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose className="...">Cancel</DrawerClose>
<DrawerClose className="...">Save</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Examples
Default
A trigger that opens a right-side panel with a header, body, and footer.
Sides
Slide the panel in from the right, left, top, or bottom edge.
Sizes
Five widths for left/right panels — sm, md, lg, xl, and full.
Rounded
Corner-rounding cascades from the root rounded prop to the panel's inner edge, the close button, and the drag handle.
Curving your own content
Content you put inside — a tile or thumbnail in the body — should curve like the chrome around it, or it reads as pasted on. The drawer panel publishes its inner radius as a CSS variable:
<img className="rounded-[var(--drawer-item-radius)]" />It tracks whatever the component is actually doing: the explicit step when rounded is set (8px at md), and 0 at rounded="none" — so your content squares off exactly when the container does.
Inset
inset floats the panel off the viewport edges with a safe-area-aware gap and rounds every corner — the desktop floating-panel presentation.
Scrim
Three background treatments while the drawer is open: blur frosts the page (default), dim is a plain dark overlay, and shadow keeps the page fully visible with the panel separated by elevation alone.
Non-modal
modal={false} removes the scrim and scroll lock so the page stays interactive — the inspector-panel pattern. modal="trap-focus" keeps keyboard focus inside without locking the page.
Header actions
The header's action slot renders a trailing control between the title and the close button, and the back slot renders a leading DrawerBack for multi-step flows.
Nested navigation
Drill into sub-lists inside one drawer — DrawerBack appears in the header while a section is open.
Bottom Sheet
A bottom-anchored sheet with a stacked footer and a live drag handle — swipe down (touch) or drag the handle (mouse) to dismiss.
Form in a bottom sheet
On a phone, focusing a field opens the software keyboard. Every side="bottom"
drawer handles it through Base UI's Drawer.VirtualKeyboardProvider: the sheet
grows by the keyboard's height so its content sits above the keyboard, and the
focused field scrolls into view. There is nothing to wire up.
For a layout of your own, the keyboard's height is published as
--drawer-keyboard-inset on the drawer's viewport. Always read it with the
fallback — var(--drawer-keyboard-inset, 0px) — because it is unset while no
keyboard is up. Do not set interactive-widget=resizes-content in the viewport
meta tag: the provider measures the keyboard from the visual viewport, and that
setting shrinks the layout viewport instead.
Top panel
A top-anchored panel with a tab bar under the header and a full-width call to action — the search-sheet pattern.
Navigation
A left-side navigation drawer built from DrawerItem rows — hover, focus, active, disabled, and selected states, a destructive sign-out row, and radii that follow the drawer's rounded.
Profile header
A hero header block with an avatar, grouped menu rows, and a pinned bottom row for settings.
Menu
A narrow, headerless action menu — name the dialog with aria-label on DrawerContent — with a sign-out row and a version/legal footer block.
Filter panel
Accordion filter sections with radio and checkbox groups, a results meta line, and paired Clear/Apply actions pinned below the scrolling body. Swap the primary label for a live count ("Show 260 results") when the query runs as filters change.
Form
A create-form drawer with labeled fields, a toggle row, and a wizard-style footer — leading back button, trailing Cancel/Submit.
Details
A record-details panel with a full-bleed media hero and metadata rows, plus an empty-state variant.
Snap points
A bottom sheet that rests at preset heights. Drag it between them, or activate the grabber to step through — a swipe has no keyboard equivalent, so with snap points the grabber becomes a real button and is the keyboard path to the detents.
Swipe area
An invisible edge strip that opens the drawer when swiped inward. Opt-in, and
rendered as a sibling of DrawerContent because it has to exist while the
drawer is closed. Always keep a trigger as well: the strip is aria-hidden and
unfocusable by design, and on iOS a left-edge strip competes with the system
back gesture, which usually wins.
API Reference
Drawer
Root component. Wraps the Base UI Drawer primitive and provides context for side, size, rounded, inset, modal, and dir. Swiping toward the drawer's edge dismisses it: touch swipes work anywhere on the panel (scrollable content is respected — the gesture only takes over at the scroll edge), and mouse drags work from the drag handle, so text inside the panel stays selectable.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "right" | "left" | "top" | "bottom" | "right" | Which edge the panel slides from |
size | "sm" | "md" | "lg" | "xl" | "full" | "md" | Panel width (right/left) or max-height (top/bottom) |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner rounding. Edge drawers round the inner corners; inset drawers round every corner. Cascades to the handle, close button, and skeleton |
inset | boolean | false | Float the panel off the viewport edges with a safe-area-aware gap and all-corner rounding |
dir | "ltr" | "rtl" | "ltr" | Text direction applied to the panel and its children |
open | boolean | — | Controlled open state |
defaultOpen | boolean | — | Uncontrolled initial open state |
onOpenChange | (open: boolean, event: Event) => void | — | Called when the open state changes |
onOpenChangeComplete | (open: boolean) => void | — | Called after the open/close animation settles |
dismissible | boolean | true | Whether backdrop click, Escape, and the swipe gesture close the drawer — false keeps only explicit closes |
modal | boolean | "trap-focus" | true | true traps focus, locks scroll, and shows the scrim. "trap-focus" traps focus only — no scrim, no scroll lock. false is fully non-modal: the page stays interactive |
scrim | "blur" | "dim" | "shadow" | "blur" | Background treatment while open (modal only): frosted blur, plain dark overlay, or no overlay with the panel elevated by shadow — outside clicks dismiss in all three |
snapPoints | (number | string)[] | — | Heights a side="bottom" sheet snaps to. 0–1 is a fraction of the viewport, above 1 is pixels, strings carry their own px / rem unit. Declaring them also makes the grabber operable |
snapPoint | number | string | null | — | The active snap point. Pair with onSnapPointChange to control it |
defaultSnapPoint | number | string | null | first snap point | The initial snap point when uncontrolled |
onSnapPointChange | (snapPoint) => void | — | Called when the active snap point changes |
snapToSequentialPoints | boolean | false | Disable velocity-based skipping, so drag distance alone picks the next point |
DrawerContent
Renders the panel inside a portal. Includes the backdrop automatically when the drawer is modal; modal="trap-focus" and modal={false} render no scrim.
| Prop | Type | Default | Description |
|---|---|---|---|
side | DrawerSide | from context | Override the slide-in side |
size | DrawerSize | from context | Override the panel size |
rounded | DrawerRounded | from context | Override the corner rounding |
inset | boolean | from context | Override the floating presentation |
showHandle | boolean | true for bottom, false otherwise | Show the drag handle pill |
handleLabel | string | "Resize drawer" | Accessible name for the grabber when the drawer has snap points |
The grabber has two shapes, decided by whether snap points exist rather than
by a prop. Without them it is an ornament — aria-hidden, unfocusable, because
the panel is already draggable and a control that only duplicates a gesture is
noise in the tab order. With them it is a real 44px button: activating it
advances to the next height and wraps at the end, which is also the only way a
keyboard or switch user reaches the detents.
Size values:
| Size | right / left | top / bottom |
|---|---|---|
sm | 320px | max-h 40dvh |
md | 400px | max-h 60dvh |
lg | 512px | max-h 75dvh |
xl | 640px | max-h 90dvh |
full | 100% | 100dvh |
DrawerHeader
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Leading icon rendered beside the title |
back | ReactNode | — | Leading back affordance rendered before the icon/title — typically <DrawerBack aria-label="Back" /> |
action | ReactNode | — | Trailing action cluster between the title and the close button (the Reset / Clear / Done pattern) |
showClose | boolean | true | Whether to render the built-in X close button |
The first child of DrawerHeader is treated as the title row (shares a line with the back slot, icon, action slot, and close button). All subsequent children (e.g. DrawerDescription) render full-width below it.
DrawerBack
Leading back button for multi-step or drill-down drawers. Renders an icon-only button that mirrors the close button's ergonomics — ghost, xs, radius from the drawer's rounded — and flips its arrow automatically in RTL. aria-label is required.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | required | Accessible name for the icon-only button |
| …rest | IconButton props | variant="ghost", size="xs" | Everything except icon is forwarded |
DrawerTitle
Renders as Dialog.Title. Accepts all <div> props plus className.
DrawerDescription
Renders as Dialog.Description. Accepts all <div> props plus className.
DrawerBody
Scrollable content area. Grows to fill available space between header and footer. Accepts all <div> props plus className.
DrawerFooter
Action area. For side="bottom" and side="top" drawers, buttons stack vertically (full-width). For side="left" and side="right" drawers, buttons align horizontally to the end. Pass className="flex-row items-center justify-end" to override the auto-stacking behaviour.
DrawerItem
Menu/navigation row for drawer content: icon + label + trailing cluster with hover, focus-visible, active, disabled, and selected states, a 44px touch floor on phones, and a radius one step inside the drawer's rounded. Activating a row closes the drawer unless closeOnSelect={false} (drill-down rows).
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | Leading icon (decorative — the row text is the accessible name) |
trailing | ReactNode | — | Trailing cluster: chevron, shortcut, badge |
intent | "default" | "destructive" | "default" | Destructive renders the sign-out / delete treatment |
selected | boolean | false | Marks the current row (aria-current) with the filled treatment |
closeOnSelect | boolean | true | Close the drawer when the row activates |
DrawerTrigger
Wraps the primitive's Trigger (adds data-slot="drawer-trigger"). Renders a <button> that opens the drawer. Use the render prop to swap in a custom element.
DrawerClose
Wraps the primitive's Close (adds data-slot="drawer-close"). Renders a <button> that closes the drawer. Use the render prop to swap in a custom element.
DrawerBackdrop
Pre-styled backdrop with blur and fade animation. Included automatically by DrawerContent while the drawer is modal — never add it as a sibling of DrawerContent.
Accessibility
- The panel is a Base UI dialog:
role="dialog", labeled byDrawerTitleand described byDrawerDescription. Headerless drawers must passaria-labelonDrawerContent. - Focus moves into the panel on open, is trapped while modal, and returns to the trigger on close. Escape closes when
dismissible. - Every part exposes a stable
data-slotattribute (drawer-content,drawer-header,drawer-body,drawer-footer,drawer-back, …) for testing and agent tooling. - Enter/exit runs a 450ms deceleration curve; releasing a swipe scales the exit duration by the flick velocity, so a hard flick lands fast. All of it is disabled under
prefers-reduced-motion: reduce. The swipe gesture itself is direct manipulation — the panel follows the pointer 1:1 — and remains available under reduced motion. - Swipe-to-dismiss respects scrollable content (the gesture engages at the scroll edge), skips range inputs and multi-touch, and never fights mouse text selection inside the panel body.
DrawerSwipeArea
An invisible edge strip that opens the drawer when swiped inward. Render it as a
sibling of DrawerContent — it has to exist while the drawer is closed.
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | 24 | Thickness of the grab strip in px |
DrawerProvider · DrawerIndent · DrawerIndentBackground
Opt-in page treatment: the app scales back slightly while a drawer is open, so a
sheet reads as sitting in front of the page rather than pasted onto it. Wrap the
app once in DrawerProvider, put the app inside DrawerIndent, and put
DrawerIndentBackground immediately before it. The scale is suppressed under
prefers-reduced-motion.
<DrawerProvider>
<DrawerIndentBackground />
<DrawerIndent>
{/* your app */}
</DrawerIndent>
</DrawerProvider>