vaul
Vaul is a React drawer component: the sheet that slides in from an edge of the screen and can be dragged closed with a finger, the way native iOS and Android sheets behave. It is a thin layer over @radix-ui/react-dialog, so you inherit Radix's focus trap, escape handling, portal, and accessibility wiring, and Vaul adds the pointer-drag physics, velocity-based dismissal, snap points, background scaling, and keyboard-avoidance for inputs. It ships unstyled apart from the transform and transition CSS it injects itself, which is why shadcn/ui adopted it as the implementation behind its Drawer component. That adoption is where the bulk of its download volume comes from.
Vaul is the best-feeling React drawer that exists and it is also formally unmaintained by its author's own admission. Keep it if shadcn/ui already put it in your app and it works, but do not start something new on it without accepting that you own every future bug.
Use it if
- You are already using shadcn/ui and its Drawer component: you are using Vaul whether you picked it or not, and swapping it out means rewriting that component
- You want a bottom sheet that feels native on touch: drag to dismiss with velocity, rubber-band resistance at the edges, and snap points defined as percentages or pixel heights
- You want Radix Dialog semantics for free: focus trapping, aria wiring through Drawer.Title and Drawer.Description, escape-to-close, and portal rendering are inherited rather than reimplemented
- You need the drawer to survive mobile keyboards: repositionInputs moves focused inputs above the on-screen keyboard instead of letting the browser scroll the page underneath
- You need a maintained dependency: the README's first line says the repo is unmaintained, the author states it is a hobby project he has no time for, the last release was December 2024, and the last push to the repo was October 2025 with 131 open issues
- You only need a static side panel or a plain modal with no drag gesture: @radix-ui/react-dialog alone does that in a fraction of the code and with no extra layer to debug
- You are not on React, or you are on React Server Components without a client boundary: every Vaul component is client-side and needs 'use client'
- You want CSS you control end to end: Vaul injects a stylesheet at runtime and writes transform, transition, and body styles directly, so overriding animation timing means fighting inline styles and data attributes rather than editing a class
- You need long-tail platform bugs fixed: known Safari and Android keyboard issues, and snap-point-plus-scroll conflicts, sit open in the tracker with no one triaging them
Setup reality
npm install vaul pulls @radix-ui/react-dialog as a real dependency, and React plus React DOM as peers accepting 16.8 through 19. There is no CSS file to import: Vaul injects its own stylesheet at runtime, which is convenient until it collides with your reset or a strict Content-Security-Policy that blocks inline style elements. In Next.js the App Router needs 'use client' on any file importing it. The bit that surprises people is background scaling: shouldScaleBackground only does anything if you add a data-vaul-drawer-wrapper attribute to the element wrapping your whole app, and it also needs a dark page background to not look broken. Radix will log a console warning on every open unless you render Drawer.Title, even if you hide it visually.
Patterns
A bottom drawer with a triggerbasic-bottom-drawer
'use client'
import { Drawer } from 'vaul'
export function Sheet() {
return (
<Drawer.Root>
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 left-0 right-0 rounded-t-xl bg-white p-4">
<Drawer.Title>Settings</Drawer.Title>
<Drawer.Description>Change how the app behaves.</Drawer.Description>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Content has no positioning of its own. If you forget the fixed bottom-0 left-0 right-0 classes the drawer renders in the document flow and looks broken.
Control the drawer from your own statecontrolled-open-state
const [open, setOpen] = useState(false)
<Drawer.Root open={open} onOpenChange={setOpen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 inset-x-0 bg-white p-4">
<Drawer.Title>Confirm</Drawer.Title>
<button onClick={() => setOpen(false)}>Cancel</button>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>onOpenChange fires after the close animation starts, not after it finishes. Use onAnimationEnd(open) if you need to reset form state only once the drawer is fully gone.
Snap points at fixed heightssnap-points
const [snap, setSnap] = useState<number | string | null>('148px')
<Drawer.Root
snapPoints={['148px', '355px', 1]}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
>
{/* ... */}
</Drawer.Root>Numbers between 0 and 1 are fractions of screen height; strings are raw pixels. You must own activeSnapPoint state, and the array has to be ordered least-visible first or the drag maths goes wrong.
Only fade the overlay past a given snap pointfade-overlay-from-snap
<Drawer.Root
snapPoints={[0.3, 0.6, 1]}
fadeFromIndex={1}
activeSnapPoint={snap}
setActiveSnapPoint={setSnap}
>
{/* overlay stays transparent until the 0.6 snap point */}
</Drawer.Root>fadeFromIndex is only accepted when snapPoints is set; the types make it a compile error otherwise. It defaults to the last snap point.
Slide in from the side instead of the bottomside-drawer-direction
<Drawer.Root direction="right">
<Drawer.Trigger>Filters</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed right-0 top-0 bottom-0 w-80 bg-white p-4">
<Drawer.Title>Filters</Drawer.Title>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>direction accepts top, bottom, left, right. Your own positioning classes must match the direction; Vaul only sets the transform, not the anchoring.
Drag only by the grabber, not the whole sheethandle-only-dragging
<Drawer.Root handleOnly>
<Drawer.Portal>
<Drawer.Content className="fixed bottom-0 inset-x-0 bg-white">
<Drawer.Handle className="mx-auto my-3 h-1.5 w-12 rounded-full bg-gray-300" />
<div className="overflow-y-auto p-4">{/* long content */}</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>handleOnly is the fix for drawers whose body contains sliders, maps, or horizontal carousels that would otherwise swallow or fight the drag gesture.
Scrollable body without breaking dragscrollable-content
<Drawer.Content className="fixed bottom-0 inset-x-0 flex h-[80vh] flex-col bg-white">
<Drawer.Handle className="mx-auto my-3 h-1.5 w-12 rounded-full bg-gray-300" />
<div className="flex-1 overflow-y-auto overscroll-contain p-4">
{items.map((i) => <Row key={i.id} {...i} />)}
</div>
</Drawer.Content>Vaul blocks dragging for scrollLockTimeout (100ms default) after you scroll inside the drawer. Add overscroll-contain or the scroll chains to the page behind on iOS.
Exclude an element from the drag gestureopt-out-of-drag
<Drawer.Content className="fixed bottom-0 inset-x-0 bg-white p-4">
<Drawer.Title>Volume</Drawer.Title>
<input type="range" data-vaul-no-drag min={0} max={100} />
</Drawer.Content>data-vaul-no-drag on an element (and its subtree) stops pointer moves there from being read as a drawer drag. Sliders and canvases need it.
Open a drawer from inside a drawernested-drawers
<Drawer.Root>
<Drawer.Portal>
<Drawer.Content className="fixed bottom-0 inset-x-0 bg-white p-4">
<Drawer.Title>Account</Drawer.Title>
<Drawer.NestedRoot>
<Drawer.Trigger>Delete account</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="fixed bottom-0 inset-x-0 bg-white p-4">
<Drawer.Title>Are you sure?</Drawer.Title>
</Drawer.Content>
</Drawer.Portal>
</Drawer.NestedRoot>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>The inner one must be Drawer.NestedRoot, not Drawer.Root. Using Root twice gives you two independent drawers stacked on top of each other with no parent scale-back effect.
Let users interact with the page behindnon-modal-drawer
<Drawer.Root modal={false} dismissible>
<Drawer.Portal>
<Drawer.Content className="fixed bottom-0 inset-x-0 bg-white p-4">
<Drawer.Title>Now playing</Drawer.Title>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>modal={false} drops the focus trap and the outside-click guard, so it is not appropriate for anything that needs a decision before the user continues. Omit Drawer.Overlay too, or clicks hit an invisible layer.
Scale the page back behind the drawerscale-background
// app/layout.tsx
<body>
<div data-vaul-drawer-wrapper className="min-h-screen bg-white">
{children}
</div>
</body>
// anywhere
<Drawer.Root shouldScaleBackground>{/* ... */}</Drawer.Root>Nothing happens without the data-vaul-drawer-wrapper element, and Vaul also darkens the body background unless you pass setBackgroundColorOnScale={false}.
Render the drawer inside a specific elementrender-into-container
const [container, setContainer] = useState<HTMLElement | null>(null)
<div ref={setContainer} className="relative overflow-hidden h-[600px]" />
<Drawer.Root container={container}>
<Drawer.Portal>
<Drawer.Content className="absolute bottom-0 inset-x-0 bg-white p-4" />
</Drawer.Portal>
</Drawer.Root>Use a state ref callback, not useRef: the container is null on first render and a plain ref will not re-render the Root when it fills in. Switch Content from fixed to absolute when scoping to a container.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @radix-ui/react-dialog | npm | You want the modal and side panel behavior without drag gestures; this is what Vaul is built on anyway |
| react-modal-sheet | npm | You want a bottom sheet with snap points from a project that still ships releases |
| @silk-hq/components | npm | You want native-feeling sheets and stacked navigation as a supported commercial component set rather than an abandoned hobby project |