Skip to content
Redpanda UI
Esc
↑↓navigate↵open⌘Jpreview
Edit on GitHub

Drawer

A panel that slides in from an edge of the screen and can be swiped away.

Installation

bash
npx shadcn@latest add @redpanda/drawer
bash
pnpm dlx shadcn@latest add @redpanda/drawer
bash
yarn dlx shadcn@latest add @redpanda/drawer
bash
bunx shadcn@latest add @redpanda/drawer
bash
nubx shadcn@latest add @redpanda/drawer
bash
aube dlx shadcn@latest add @redpanda/drawer

When to use

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

A drawer and a 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

plaintext
<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.

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.

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.

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.

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}.

Action sheet

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

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 packages the same pattern.

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

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

PropType
direction?'top' | 'right' | 'bottom' | 'left'

The edge the drawer slides in from. Also sets the swipe that dismisses it.

Type'top' | 'right' | 'bottom' | 'left'
Default'bottom'
container?HTMLElement | null

Where the drawer portals to. A container on DrawerContent wins.

TypeHTMLElement | null
modal?boolean | 'trap-focus'

false leaves the page usable: no overlay, focus trap or scroll lock, and outside clicks leave the drawer open.

Typeboolean | 'trap-focus'
Defaulttrue
dismissible?boolean

When false, Escape, outside clicks, focus leaving the drawer and swipes no longer close it. DrawerClose, the trigger and a controlled open still do.

Typeboolean
Defaulttrue
disablePointerDismissal?boolean

Keep the drawer open on outside clicks. Defaults to true when modal is false or dismissible is false.

Typeboolean
onOpenChange?(open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => void

Called with the next open state and the event details (reason, cancel()).

Type(open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => void

DrawerContent

PropType
size?'md' | 'half'

Width of a left or right drawer. Does not apply to top or bottom drawers.

Type'md' | 'half'
Default'md'
showOverlay?boolean

Render the overlay behind a modal drawer. A non-modal drawer never has one.

Typeboolean
Defaulttrue
container?HTMLElement

Render inside this element instead of the document body. The drawer is then positioned within it.

TypeHTMLElement
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.

Typeboolean | RefObject<HTMLElement | null> | ((openType) => boolean | HTMLElement | null | void)
finalFocus?boolean | RefObject<HTMLElement | null> | ((closeType) => boolean | HTMLElement | null | void)

Where focus goes on close. Defaults to the trigger.

Typeboolean | RefObject<HTMLElement | null> | ((closeType) => boolean | HTMLElement | null | void)
testId?string

Sets data-testid on the drawer panel.

Typestring

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 for the drawer component.
  • We take our inspiration from Shadcn UI for the drawer style.

Recent changes

  • patchv1.2.0Pin shipped dependency floors to the version we develop against. Registry items now declare ranges like `^5.1.9` (the actual installed version) instead of collapsing to `^5.0.0`, so consumers start on the known-tested baseline while caret semantics still allow any compatible release within the same major.#133
  • minorv1.1.0Theme docs refresh, readability pass on semantic foregrounds, and consumer-facing Base UI regression fixes.#121
  • minorv1.0.0Post-Base-UI polish. Public API unchanged.#116
  • majorv1.0.0Migrate every Radix-based primitive to `@base-ui/react@^1.4.0` (Base UI).#114
  • patchv0.3.1## 🚧 Updated#112
See full history →

Last updated on

Was this page helpful?