Components
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
open | boolean | — | Controls the open state. Omit for an uncontrolled drawer managed internally. |
defaultOpen | boolean | false | Open 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. |
modal | boolean | true | When true, interaction with outside elements is blocked while open. |
dismissible | boolean | true | When false, dragging, clicking outside and Escape all stop closing the drawer — pair it with a controlled open prop or there is no way out. |
closeThreshold | number | 0.25 | Fraction of the drawer that has to be dragged away before releasing closes it. |
shouldScaleBackground | boolean | false | Scales 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. |
fadeFromIndex | number | last snap point | Index of the snap point from which the overlay starts to fade. Only valid alongside snapPoints. |
activeSnapPoint | number | 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. |
handleOnly | boolean | false | Restricts 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. |
autoFocus | boolean | true | On 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. |
nested | boolean | false | Marks a drawer opened from inside another drawer so the two stack correctly. |
childrenrequired | React.ReactNode | — | Trigger and content subcomponents. |
Parts
Every part accepts className, which is merged onto the defaults with cn().
| Prop | Type | Default | Description |
|---|---|---|---|
Drawer | vaul Drawer.Root | — | Stateful container. Every prop in the table above is set here. |
DrawerTrigger | vaul Drawer.Trigger | — | Element that opens the drawer. Use asChild to render your own Button. |
DrawerPortal | vaul Drawer.Portal | — | Portal target. DrawerContent renders one already — reach for it only when building a custom panel shell. |
DrawerOverlay | vaul Drawer.Overlay | — | Dimmed backdrop (bg-ink/60, dark:bg-ink/80, backdrop-blur). Also rendered for you by DrawerContent. |
DrawerContent | vaul 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. |
DrawerHeader | div | — | Title region, p-4. Text is centred for top and bottom drawers and left-aligned from md up. |
DrawerTitle | vaul Drawer.Title | — | Accessible name for the panel. Required for screen-reader announcements. |
DrawerDescription | vaul Drawer.Description | — | Supporting text linked to the panel via aria-describedby. |
DrawerFooter | div | — | Actions region: mt-auto pins it to the base of the panel, p-4 matches the header. |
DrawerClose | vaul 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
autoFocustotrue. WithautoFocus={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, unlikeDialogContent, has no focus return of its own — so a drawer opened from state returns focus inonCloseAutoFocus, 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 withDrawerDescriptionfor context. - Pressing Esc or clicking the overlay dismisses the drawer unless
dismissibleis false. - The grab handle is decorative and not focusable — dragging is a pointer gesture only, so a
DrawerClosebutton is the keyboard route out and is never optional.