Drawer

A panel that slides in from any edge of the screen, built on Vaul with smooth drag-to-dismiss gestures and a dimmed overlay.

Basic usage

Compose a DrawerTrigger with DrawerContent. State is managed internally — no useState required. Unlike Sheet there is no corner ✕, so always leave a DrawerClose in the footer.

Direction

Set direction on the root to slide in from any edge. Vaul stamps the choice onto the panel as data-vaul-drawer-direction, and the layout follows from there: side drawers cap at sm:max-w-sm, top and bottom at max-h-[80vh] with the rounded edge facing the screen.

Grab handle

Bottom drawers get a grab handle at the top of the panel; the other three directions do not render one. It is painted with bg-control-edge rather than bg-muted on purpose: the bar is the only sign that says “drag here”, so it owes the 3:1 of WCAG 1.4.11 rather than the looser bar for a surface. Against the panel background bg-muted measures 1.07:1 in light and 1.17:1 in dark — effectively invisible — while bg-control-edge lands at 3.83:1 and 4.94:1.

Scrollable content

The header and footer stay pinned while a flex-grown body scrolls. Wrap long content in an overflow-y-auto region.

Focus on open

Since 2.1.0 the package's Drawer defaults autoFocus to true — the opposite of vaul. Opening moves focus to the first focusable element inside, Tab cycles within the panel, and closing hands focus back to a DrawerTrigger, as Dialog and Sheet do with theirs. Without a Trigger the Drawer is the odd one out — see Focus on close below. Under vaul's own default — the package's behaviour until 2.1.0 — the content calls preventDefault() on Radix Dialog's onOpenAutoFocus, so focus stays on the trigger behind the overlay, the focus trap never engages — it only holds focus that is already inside — and Tab walks the page behind a modal (WCAG 2.4.3).

The price is that a leading input takes focus, and on a phone that opens the keyboard over half the drawer. To start somewhere else, call event.preventDefault() in onOpenAutoFocus on DrawerContent and focus the element you want — below, the status filter rather than the search field. <Drawer autoFocus={false}> restores vaul's behaviour, but then moving focus inside is the app's job; skip it and the drawer is a modal the keyboard walks straight through.

Focus on close

With a DrawerTrigger, closing hands focus back to it — Radix Dialog does that under vaul. Open the drawer from state instead — a table row, a menu item, a keyboard shortcut — and closing drops focus on <body>, so the next Tab starts again from the top of the page (WCAG 2.4.3). Here the Drawer parts ways with the package’s other modals: DialogContent and SheetContent return focus to their opener since 3.3.0, and AlertDialogContent since 3.3.1, but DrawerContent does not — vaul manages focus its own way.

So keep a DrawerTrigger where you can, and where you cannot, return focus in onCloseAutoFocus: call event.preventDefault() and focus the element that opened the drawer — or, when that was a menu item, the menu’s trigger button, since the item unmounts with the menu. The same holds for the Drawer branch of ResponsiveDialog.

focus-on-close.tsx
import { useRef, useState } from 'react'
import {
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerDescription,
  DrawerFooter,
  DrawerClose,
  Button,
} from '@comitor/ui'

export function Example() {
  const [open, setOpen] = useState(false)
  const openerRef = useRef<HTMLButtonElement>(null)

  return (
    <>
      {/* Not a DrawerTrigger: the drawer opens from state, as it would from a row or a menu item. */}
      <Button ref={openerRef} variant="outline" onClick={() => setOpen(true)}>
        Task details
      </Button>
      <Drawer open={open} onOpenChange={setOpen}>
        <DrawerContent
          // DrawerContent has no focus return of its own — without this, closing leaves
          // focus on <body>. Opened from a DropdownMenu item, focus the menu's trigger
          // button instead: the item unmounts with the menu.
          onCloseAutoFocus={(event) => {
            event.preventDefault()
            openerRef.current?.focus()
          }}
        >
          <DrawerHeader>
            <DrawerTitle>Task details</DrawerTitle>
            <DrawerDescription>Opened from state, closed back onto its button.</DrawerDescription>
          </DrawerHeader>
          <DrawerFooter>
            <DrawerClose asChild>
              <Button variant="outline">Close</Button>
            </DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    </>
  )
}

Drawer or Sheet?

Drawer is built on vaul: the panel follows the finger, carries momentum, and can be flicked away — the right feel on a touch screen. Sheet is a Radix Dialog that slides on a fixed track with a corner close button, which suits desktop filters and detail panels. Reach for Drawer when the gesture matters, Sheet when it does not. When one modal should be a Dialog on a desktop and a Drawer on a phone, use ResponsiveDialog: it switches between the two, its drawer branch inherits the focus behaviour above — the gap on close included — and onOpenAutoFocus and onCloseAutoFocus work in both branches.

Props

Props below are set on the root Drawer. All subcomponents forward their native and Vaul props.

PropTypeDefaultDescription
direction'top' | 'bottom' | 'left' | 'right''bottom'Edge the drawer slides in from. Set on the root Drawer — vaul writes it onto the content as data-vaul-drawer-direction, which is what the styling keys off.
openboolean—Controls the open state. Omit for an uncontrolled drawer managed internally.
defaultOpenbooleanfalseOpen on mount, skipping the enter animation. Still reacts to later open changes.
onOpenChange(open: boolean) => void—Called when the drawer is requested to open or close.
modalbooleantrueWhen true, interaction with outside elements is blocked while open.
dismissiblebooleantrueWhen false, dragging, clicking outside and Escape all stop closing the drawer — pair it with a controlled open prop or there is no way out.
closeThresholdnumber0.25Fraction of the drawer that has to be dragged away before releasing closes it.
shouldScaleBackgroundbooleanfalseScales the page behind the drawer for the iOS-style stacked effect. Requires a [vaul-drawer-wrapper] element around the page content.
snapPoints(number | string)[]—Optional snap positions for partially-open states, from least to most visible — e.g. [0.4, 0.8, 1] or px strings.
fadeFromIndexnumberlast snap pointIndex of the snap point from which the overlay starts to fade. Only valid alongside snapPoints.
activeSnapPointnumber | string | null—Controlled active snap point; pair with setActiveSnapPoint.
setActiveSnapPoint(snapPoint: number | string | null) => void—Setter for the controlled activeSnapPoint — vaul calls it when the user drags between snap points.
handleOnlybooleanfalseRestricts dragging to a vaul <Drawer.Handle />. The package draws its own handle as a plain div and does not re-export Handle, so turning this on leaves nothing draggable — leave it off.
autoFocusbooleantrueOn open, move focus to the first focusable element inside, keep Tab cycling within the panel, and return focus to a DrawerTrigger on close. The package flips vaul's own default, false, which left focus on the trigger behind the overlay (WCAG 2.4.3). Pass false to get vaul's behaviour back — the app must then move focus in itself.
nestedbooleanfalseMarks a drawer opened from inside another drawer so the two stack correctly.
childrenrequiredReact.ReactNode—Trigger and content subcomponents.

Parts

Every part accepts className, which is merged onto the defaults with cn().

PropTypeDefaultDescription
Drawervaul Drawer.Root—Stateful container. Every prop in the table above is set here.
DrawerTriggervaul Drawer.Trigger—Element that opens the drawer. Use asChild to render your own Button.
DrawerPortalvaul Drawer.Portal—Portal target. DrawerContent renders one already — reach for it only when building a custom panel shell.
DrawerOverlayvaul Drawer.Overlay—Dimmed backdrop (bg-ink/60, dark:bg-ink/80, backdrop-blur). Also rendered for you by DrawerContent.
DrawerContentvaul Drawer.Content—The panel: portal + overlay + a flex column, plus the grab handle on bottom drawers. It has no built-in close button — supply a DrawerClose yourself. Radix Dialog.Content props pass through, among them onOpenAutoFocus, which decides where focus lands on open, and onCloseAutoFocus, which has to return it when the drawer was opened without a DrawerTrigger.
DrawerHeaderdiv—Title region, p-4. Text is centred for top and bottom drawers and left-aligned from md up.
DrawerTitlevaul Drawer.Title—Accessible name for the panel. Required for screen-reader announcements.
DrawerDescriptionvaul Drawer.Description—Supporting text linked to the panel via aria-describedby.
DrawerFooterdiv—Actions region: mt-auto pins it to the base of the panel, p-4 matches the header.
DrawerClosevaul Drawer.Close—Dismisses the drawer. Use asChild to wrap a custom Button.

Accessibility

  • Focus moves into the drawer on open and Tab stays inside it, because the package defaults autoFocus to true. With autoFocus={false} (vaul's default) neither happens until the app moves focus in itself.
  • On close, focus returns to the DrawerTrigger. Without one it falls to <body> — DrawerContent, unlike DialogContent, has no focus return of its own — so a drawer opened from state returns focus in onCloseAutoFocus, as shown under Focus on close.
  • The overlay and surrounding page are marked inert in modal mode, so screen readers stay scoped to the drawer.
  • Always include a DrawerTitle — it labels the dialog for assistive tech; pair it with DrawerDescription for context.
  • Pressing Esc or clicking the overlay dismisses the drawer unless dismissible is false.
  • The grab handle is decorative and not focusable — dragging is a pointer gesture only, so a DrawerClose button is the keyboard route out and is never optional.