Skip to content
Utilities

Vaul — Component Library Guide

An unstyled drawer component for React. Smooth drag-to-dismiss interactions, snap points, and native-feeling mobile drawer UX - built on Radix Dialog.

TypeScript MIT
780K weekly downloads
5.6k GitHub stars
~7kB bundle size
v1.1.2 latest version
drawerbottom-sheetmobilegesturessnap-points

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.Content with overflow-y-auto only 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 set vaul-drawer-wrapper on 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.
  • 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

Frequently Asked Questions

Is Vaul only for mobile interfaces?

Vaul is optimised for mobile-style drawer interactions, but it works on desktop too. Many products use Vaul on all screen sizes for a consistent bottom-sheet pattern. On desktop you may prefer a side drawer or a standard Dialog component instead.

How do I add snap points to a Vaul drawer?

Pass a snapPoints array to Drawer.Root with values between 0 and 1 representing fractional viewport height. Use activeSnapPoint and setActiveSnapPoint for controlled state. The drawer will snap to the nearest point on drag release.

How do I prevent the drawer from closing when clicking the overlay?

Pass dismissible={false} to Drawer.Root. This is useful for drawers that require the user to complete an action before dismissing, such as a multi-step form or required confirmation.

What is the shouldScaleBackground prop?

When set to true, the background content scales down slightly as the drawer opens, creating a native iOS-style effect. It requires a fixed-height root element. Set it on Drawer.Root and wrap your app content in Drawer.NestedRoot if you need nested drawers.

Can I scroll content inside a Vaul drawer?

Yes. Add overflow-y-auto to the Drawer.Content inner container. Be careful at partial snap points - at reduced height, scrolling can conflict with the drag gesture. Restrict scrollable content to when the drawer is at its full snap point.