TL;DR: Vaul is a bottom-sheet drawer for React with native-feeling drag-to-dismiss. Built on Radix Dialog with spring animations, snap points, and velocity-based closing. Drop-in for mobile-style interactions on web. ~7kB. Official docs: vaul.emilkowal.ski.
What is Vaul?
Vaul is a bottom-sheet / drawer component for React with native-feeling drag gestures. Built on top of Radix Dialog, it adds drag-to-close, velocity-based dismissal, snap points (like iOS sheets), and smooth spring animations. It's the right component for mobile-style interactions in web apps.
What sets it apart is the feel. A lot of web drawers slide in and out on a fixed timer, which reads as "web" the moment a user touches it. Vaul tracks your finger frame by frame, then hands off to a spring when you let go. Flick it down fast and it closes; nudge it gently and it settles back. That velocity-aware behavior is the difference between a drawer that feels like an app and one that feels like a modal pretending to be a drawer.
Because Vaul wraps Radix Dialog, you inherit all the accessibility plumbing underneath: focus trapping, Escape to close, ARIA roles, and scroll locking on the body. You get native-quality gestures on top, without giving up keyboard and screen reader support.
Good fits for a drawer
Use Vaul for mobile action sheets, where contextual actions slide up from the bottom. It also works well for detail panels that show more info without a full page transition, for filter and sort drawers on mobile where a modal feels too heavy, and for progressive disclosure where content expands in stages through snap points. Checkout flows lean on it too. A cart summary that pulls up from the bottom keeps the product in view while letting the user review the order.
On desktop, a standard Dialog (Radix, shadcn/ui) is usually more appropriate, or a side drawer if you want a persistent panel. That said, plenty of products keep Vaul on every breakpoint for a single consistent pattern. There's no rule against a bottom sheet on a laptop, it just isn't the convention most users expect there.
When should you not use Vaul? If the content is a destination rather than a quick task, route to a real page. Drawers are for transient, dismissible context, not for screens a user might want to bookmark or share.
Key Features
- Drag to dismiss - smooth physics-based gesture with velocity detection
- Snap points - drawer can stop at multiple heights (
snapPoints={[0.4, 1]}) - Spring animations - motion feels native iOS/Android quality
- Built on Radix Dialog - inherits full accessibility (ARIA, keyboard, focus trap)
- Nested drawers - drawers can open other drawers
Installation
npm install vaul
Basic Usage
import { Drawer } from 'vaul'
function MobileSheet() {
return (
<Drawer.Root>
<Drawer.Trigger asChild>
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg">
Open sheet
</button>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 mt-24 flex flex-col rounded-t-2xl bg-white">
{/* Drag handle */}
<div className="mx-auto mt-4 h-1.5 w-12 rounded-full bg-slate-300" />
<div className="p-6">
<Drawer.Title className="text-lg font-semibold">Options</Drawer.Title>
<Drawer.Description className="mt-2 text-sm text-slate-500">
Choose an action to perform.
</Drawer.Description>
{/* content */}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}
Snap Points
// Drawer stops at 40% height first, then can expand to full height
<Drawer.Root snapPoints={[0.4, 1]} activeSnapPoint={snap} setActiveSnapPoint={setSnap}>
<Drawer.Content>
<div className={`overflow-y-auto ${snap === 1 ? 'h-full' : 'h-[40vh]'}`}>
{/* long content */}
</div>
</Drawer.Content>
</Drawer.Root>
TypeScript Tips
Use Drawer.Root's onOpenChange for controlled state:
const [open, setOpen] = useState(false)
<Drawer.Root open={open} onOpenChange={setOpen}>
{/* ... */}
</Drawer.Root>
The scroll-versus-drag problem, explained
The trickiest part of any bottom sheet is deciding what a downward swipe means. Inside a scrollable list, dragging down should scroll the list. But near the top of that list, dragging down should pull the whole drawer closed. Vaul handles most of this for you by only engaging the close gesture when the inner content is already scrolled to the top. Still, you can confuse it.
The usual fix is to gate scrolling by snap point. At a partial snap point the content shouldn't scroll at all, so the gesture stays unambiguous: a drag means "move the drawer." Only when the drawer reaches its full height do you let the inner container become scrollable. The code example above shows that pattern with a conditional overflow-y-auto. Get this right and the drawer feels effortless. Get it wrong and users fight the sheet every time they try to read a long list.
Common Gotchas
- Drag handle is not included - Vaul doesn't render a visual drag handle; add your own (a short rounded
<div>centered at the top of the content). Users look for that little grabber bar, so skipping it hurts discoverability. - Scroll conflict - if the drawer content has a scrollable list, add
Drawer.Contentwithoverflow-y-autoonly when the drawer is at full height; otherwise scrolling interferes with dragging. shouldScaleBackground- pass this prop to<Drawer.Root>to scale the background content down when the drawer opens, for a native iOS-style effect. It needs a fixed-height root element, so setvaul-drawer-wrapperon a wrapping container.- Reduced motion - honor
prefers-reduced-motion. The spring animation is lovely, but some users need it toned down, and skipping that check is an accessibility miss.
Related
- Framer Motion - for more customised drawer animations beyond Vaul's built-in gesture handling
- Headless UI - if you need a Dialog rather than a bottom-sheet drawer, Headless UI's Dialog is the complement
- shadcn/ui - shadcn/ui's Sheet component is built on Vaul; use it if you want pre-styled drawer variants