Drawer
Floating side panel with drag-to-dismiss, built on Vaul.
<Drawer> <DrawerTrigger asChild> <Button variant="outline">Open drawer</Button> </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Move goal</DrawerTitle> <DrawerDescription>Set your daily activity goal.</DrawerDescription> </DrawerHeader> <DrawerFooter> <DrawerClose asChild> <Button variant="outline">Cancel</Button> </DrawerClose> <Button>Submit</Button> </DrawerFooter> </DrawerContent></Drawer>Usage
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@reva/ui";Drawer wraps Vaul and floats a panel inset from a viewport edge (right by default) — an 8px gap and rounded-xl corners on every side, visually aligned with the Sidebar — with native drag-to-dismiss interactions. Pick the right overlay primitive for the job:
- Drawer — floating side panel that slides in from the right (or any edge), with drag-to-dismiss as part of the affordance. The default for contextual detail, filters, and mobile-friendly forms.
- Sheet — side panel flush to the left, right, top, or bottom edge of the viewport (full-height, no inset, no drag handle).
- Dialog — centred modal for focused tasks or confirmations that don't have a directional anchor.
Composition
Use the following composition to build a Drawer:
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
└── DrawerFooter
├── DrawerClose (outline secondary)
└── (solid primary action)Footer actions
DrawerFooter lays actions out in a row and stretches them to fill it: one action spans the full width; two actions split it half-and-half. Drawer footer actions never stack vertically, and a drawer takes at most two — a third action belongs in the body or a menu. Order and variants follow the Dialog recipe: secondary (variant="outline") first, solid primary last, default button size.
Examples
Scrollable content
The primary pattern: a right-anchored panel with a scrolling body. DrawerHeader and DrawerFooter are both shrink-0 and pin themselves; give the body min-h-0 flex-1 overflow-y-auto. min-h-0 lets the flex item shrink below its intrinsic content height — without it, the viewport pushes past the drawer bounds and the scrollbar never engages.
<Drawer> <DrawerTrigger asChild> <Button variant="outline">Scrollable Content</Button> </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Move Goal</DrawerTitle> <DrawerDescription>Set your daily activity goal.</DrawerDescription> </DrawerHeader> <div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6"> <VStack gap={4}> {paragraphKeys.map((id) => ( <Text key={id}>{LOREM}</Text> ))} </VStack> </div> <DrawerFooter> <DrawerClose asChild> <Button variant="outline">Cancel</Button> </DrawerClose> <Button>Submit</Button> </DrawerFooter> </DrawerContent></Drawer>Sides
right is the default anchor. Set the direction prop to float the panel from another edge instead — these are secondary to the right-hand panel. Every anchor keeps the same 8px inset and rounded-xl corners; top and bottom drawers fill the viewport horizontally, left and right cap at sm (24rem) on sm: and up. The drag handle only renders for the bottom anchor.
const SIDES = ["top", "bottom", "left"] as const;export function DrawerSidesDemo() { return ( <HStack gap={2}> {SIDES.map((side) => ( <Drawer key={side} direction={side}> <DrawerTrigger asChild> <Button variant="outline" className="capitalize">{side}</Button> </DrawerTrigger> <DrawerContent>{/* header / footer / content */}</DrawerContent> </Drawer> ))} </HStack> );}With form
Compose Field primitives inside the drawer body for short, mobile-friendly forms.
<Drawer> <DrawerTrigger asChild> <Button variant="outline">Edit profile</Button> </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Edit profile</DrawerTitle> <DrawerDescription>Update your details and save when you are done.</DrawerDescription> </DrawerHeader> <div className="px-6"> <FieldGroup> <Field> <FieldLabel htmlFor="drawer-form-name">Name</FieldLabel> <Input id="drawer-form-name" defaultValue="Ada Lovelace" /> </Field> <Field> <FieldLabel htmlFor="drawer-form-email">Email</FieldLabel> <Input id="drawer-form-email" type="email" defaultValue="ada@reva.test" /> </Field> </FieldGroup> </div> <DrawerFooter> <DrawerClose asChild> <Button variant="outline">Cancel</Button> </DrawerClose> <Button>Save changes</Button> </DrawerFooter> </DrawerContent></Drawer>Responsive
Pair a useMediaQuery hook with conditional rendering to swap surfaces — Dialog from the md breakpoint up, Drawer below it. Both share the same trigger label and content. CSS-only swaps (e.g. hidden md:block) leave both surfaces mounted in the DOM and Vaul's anchored positioning won't paint correctly, so render only one at a time.
"use client";import { Button, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger,} from "@reva/ui";import { useMediaQuery } from "@/lib/use-media-query";export function DrawerResponsiveDemo() { const isDesktop = useMediaQuery("(min-width: 768px)"); if (isDesktop) { return ( <Dialog> <DialogTrigger asChild> <Button variant="outline">Edit profile</Button> </DialogTrigger> <DialogContent>{/* … */}</DialogContent> </Dialog> ); } return ( <Drawer> <DrawerTrigger asChild> <Button variant="outline">Edit profile</Button> </DrawerTrigger> <DrawerContent>{/* … */}</DrawerContent> </Drawer> );}Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state on Drawer root. |
defaultOpen | boolean | — | Initial uncontrolled open state. |
onOpenChange | (open: boolean) => void | — | Fires when the open state changes. |
direction | "top" | "bottom" | "left" | "right" | "right" | Edge the drawer floats in from. Drag handle only renders when "bottom". |
modal | boolean | true | Whether the drawer blocks interaction with the page. |
dismissible | boolean | true | Allow closing by drag-to-dismiss or overlay click. |
shouldScaleBackground | boolean | false | Scale background content while the drawer is open (mobile-app feel). |
showCloseButton | boolean | true | Render the corner CloseButton on DrawerContent. Auto-hidden on bottom-anchored drawers — the Vaul drag handle is the dismiss affordance there. Set to false to suppress the button on top/left/right anchors when an external dismiss control covers it. |