mrkeyoor.com_
Thu 06 Aug 02:43 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5The Drawer.Root/Content/Overlay/Handle surface has not changed since 1.0 and it will not change, because nobody is shipping releases. Stability by abandonment still counts as stability for existing code.
Docs3/5vaul.emilkowal.ski has clear live examples for snap points, direction, nested drawers, and scaled background, but the prop reference is thin and many props (setBackgroundColorOnScale, snapToSequentialPoint, repositionInputs) are only documented as JSDoc comments in the type definitions.
Maintenance1/5The README states the repo is unmaintained. Last publish December 2024, last repo push October 2025, 131 open issues and no triage. Nothing about that is going to improve on its own.
Ecosystem4/537.6M weekly downloads and 8.5K stars, almost entirely because shadcn/ui's Drawer wraps it, so tutorials and Stack Overflow answers are plentiful even though the project itself is dormant.

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
Skip it if

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

PackageRegistryPick it when
@radix-ui/react-dialognpmYou want the modal and side panel behavior without drag gestures; this is what Vaul is built on anyway
react-modal-sheetnpmYou want a bottom sheet with snap points from a project that still ships releases
@silk-hq/componentsnpmYou want native-feeling sheets and stacked navigation as a supported commercial component set rather than an abandoned hobby project