Drawer
A panel that slides in from an edge of the screen and can be swiped away.
Installation
npx shadcn@latest add @redpanda/drawerpnpm dlx shadcn@latest add @redpanda/draweryarn dlx shadcn@latest add @redpanda/drawerbunx shadcn@latest add @redpanda/drawernubx shadcn@latest add @redpanda/draweraube dlx shadcn@latest add @redpanda/drawerWhen 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
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from '@/components/redpanda-ui/drawer';<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
<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:
<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.
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.
<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:
<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
direction?'top' | 'right' | 'bottom' | 'left'
The edge the drawer slides in from. Also sets the swipe that dismisses it.
'top' | 'right' | 'bottom' | 'left''bottom'container?HTMLElement | null
Where the drawer portals to. A container on DrawerContent wins.
HTMLElement | nullmodal?boolean | 'trap-focus'
false leaves the page usable: no overlay, focus trap or scroll lock, and outside clicks leave the drawer open.
boolean | 'trap-focus'truedismissible?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.
booleantruedisablePointerDismissal?boolean
Keep the drawer open on outside clicks. Defaults to true when modal is false or dismissible is false.
booleanonOpenChange?(open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => void
Called with the next open state and the event details (reason, cancel()).
(open: boolean, eventDetails: Drawer.Root.ChangeEventDetails) => voidDrawerContent
size?'md' | 'half'
Width of a left or right drawer. Does not apply to top or bottom drawers.
'md' | 'half''md'showOverlay?boolean
Render the overlay behind a modal drawer. A non-modal drawer never has one.
booleantruecontainer?HTMLElement
Render inside this element instead of the document body. The drawer is then positioned within it.
HTMLElementinitialFocus?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.
boolean | 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.
boolean | RefObject<HTMLElement | null> | ((closeType) => boolean | HTMLElement | null | void)testId?string
Sets data-testid on the drawer panel.
stringStyling 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
DrawerDescriptionwhen 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
DrawerClosein the drawer, or rely on Escape and the overlay. - Under
prefers-reduced-motion: reducethe drawer opens and closes without sliding.