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)

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

PropTypeDefaultDescription
openbooleanControlled open state on Drawer root.
defaultOpenbooleanInitial uncontrolled open state.
onOpenChange(open: boolean) => voidFires when the open state changes.
direction"top" | "bottom" | "left" | "right""right"Edge the drawer floats in from. Drag handle only renders when "bottom".
modalbooleantrueWhether the drawer blocks interaction with the page.
dismissiblebooleantrueAllow closing by drag-to-dismiss or overlay click.
shouldScaleBackgroundbooleanfalseScale background content while the drawer is open (mobile-app feel).
showCloseButtonbooleantrueRender 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.

On this page