We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide22 min read

Vaul, an iOS style drawer component for React

Vaul is a React drawer component with gestures and snap points. The author declared it unmaintained, so weigh the alternatives first.

Vaul, an iOS style drawer component for React

Vaul is a drawer component sliding in from the edge of the screen, designed to behave like the panels familiar from iOS: dragging with a finger, resistance at the edge, settling at predefined heights. It ships under MIT at version 1.1.2, published in December 2024, and builds on the dialog primitive from Radix UI.

Project status, read this before installing

This is the most important information in this text, and you will not find it in most material about the library, because that material predates the change.

The author declared the project unmaintained. In the repository's readme he wrote plainly that this is a hobby project he currently lacks the time and motivation for, and that he might return to it at some point but not in the near future. The last change to the repository dates from 3 October 2025 and was precisely that explanation. The last code changes are older still, from July 2025, and over a hundred and fifty issues stand open.

That does not mean the library is unusable. The code works, it is small, and the problem it solves does not shift month to month. It does mean you should not count on bug fixes or on compatibility with future React versions, and that every problem you hit is one you will fix or work around yourself.

The consequences are already visible in the ecosystem. Base UI released its own drawer component, and shadcn/ui switched its drawer to that implementation. A derivative project also appeared, pairing Vaul's behaviour with a Base UI foundation.

The practical recommendation follows: on a new project, first check whether the component set you use already ships a drawer. On an existing project there is no urgency, but the swap is worth planning around your next major React upgrade, since that is the most likely moment something breaks.

What this component does well

It is worth describing why the library became popular, because that tells you what to look for in a replacement.

The foundation is a faithful reproduction of behaviour familiar from a phone. The drawer follows your finger, carries momentum, closes on a fast flick even before reaching the end, and springs back on a slow one. Those details decide whether an interface feels native, and writing them yourself takes longer than anyone who has not tried assumes.

The second thing is snap points. A drawer can have several fixed heights it jumps between, which supports a panel partly visible at the bottom, expandable to half and to full view. That is the pattern familiar from maps and music players.

The third is the accessibility layer inherited from the Radix UI dialog: focus trapping, escape to close, correct role attributes, and background scroll locking. None of that had to be written, because the library wraps an existing solution.

The fourth is the absence of its own styling. The component supplies behaviour and structure while you assemble the appearance, most often with Tailwind CSS classes. That lets it fit any design system rather than imposing one.

Drawer or ordinary dialog

This distinction decides whether the component is needed at all, and it tends to be skipped.

A drawer wins on a phone and for content the user browses rather than merely confirms. Sliding up from the bottom puts it in thumb reach, it closes with a gesture instead of aiming at a small button, and it does not cover the whole screen, so context stays visible. Filters on a product list, details of a map pin, choosing from a long list.

An ordinary dialog wins on desktop and for decisions demanding attention. Confirming a deletion, a payment form, a message blocking further work. There interrupting context is the point, and a drag gesture adds nothing, since the user is holding a mouse anyway.

The arrangement that has caught on in practice uses both and switches on viewport width: a drawer below a threshold, a dialog above it. The cost is maintaining two variants of the same content, so it pays to extract the content into its own component and drop it into one wrapper or the other.

One technical trap deserves remembering with that switch. Checking window width works only in the browser, so with server rendering the first result is a guess and may change once it reaches the user. That shows up as a flash: for a fraction of a second one variant is visible, then the other. The remedy is either basing the switch on media queries in styles rather than in code, or deliberately delaying that fragment's render until the width is known.

The second trap concerns the on screen keyboard. A drawer holding a text field behaves differently on a phone depending on the system: the keyboard can cover it, push it up, or change the height of the visible area. Check that on a real device, since the emulator in browser tooling does not reproduce that behaviour.

What is Vaul?

Vaul is an unstyled drawer (slide-out panel) component for React created by Emil Kowalski - the same creator who gave us Sonner (toast notifications). Vaul is modeled after native iOS drawers, offering smooth touch gestures, snap points (stopping positions), and support for nested drawers.

Design philosophy

Vaul was built with a few key principles in mind:

  1. Unstyled - Zero default styles, full control over appearance
  2. Accessible - WAI-ARIA compliant, keyboard support, focus management
  3. Mobile-first - Optimized for touch devices
  4. Composable - Composition-based API (Radix-style)
  5. Performant - Smooth animations without lag even on lower-end devices

Why drawer instead of modal?

Drawers (bottom sheets) have become the standard in mobile applications because:

  • Ergonomics - Easier thumb access on large phones
  • Context - The user can still see part of the previous screen
  • Naturalness - The sliding gesture is intuitive
  • Progressive disclosure - Snap points let you show a preview first

Vaul brings these advantages to web applications while preserving the native iOS/Android feel.

Why Vaul?

1. Native gestures like iOS

Vaul implements exactly the same gestures as native iOS:

  • Dragging down closes the drawer
  • A fast swipe (velocity-based) closes it immediately
  • A slow drag allows the user to pull back
  • Snapping to snap points with spring animation

2. Snap points

Snap points are positions where the drawer "stops." They allow progressive disclosure of content:

Code
TEXT
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                     β”‚ ← Full (100%)
β”‚                     β”‚
β”‚                     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ ← Half (50%)
β”‚     Drawer          β”‚
β”‚     Content         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ ← Peek (25%)
β”‚     Handle          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

3. Nested drawers

Vaul supports nested drawers - you can open a drawer from inside another drawer. This is perfect for multi-step forms or hierarchical navigation.

4. Full accessibility (a11y)

  • Focus management (focus trap)
  • Escape key to close
  • Proper ARIA roles
  • Screen reader support
  • Reduced motion for users with vestibular disorders

5. Composable API (Radix-style)

Code
TypeScript
<Drawer.Root>
  <Drawer.Trigger />
  <Drawer.Portal>
    <Drawer.Overlay />
    <Drawer.Content>
      <Drawer.Handle />
      <Drawer.Title />
      <Drawer.Description />
    </Drawer.Content>
  </Drawer.Portal>
</Drawer.Root>

6. Zero visual dependencies

Vaul does not impose any styles - style it however you want:

  • Tailwind CSS
  • CSS Modules
  • styled-components
  • Vanilla CSS
  • Emotion

Installation

Code
Bash
# npm
npm install vaul

# yarn
yarn add vaul

# pnpm
pnpm add vaul

# bun
bun add vaul

Vaul has only one dependency: @radix-ui/react-dialog, which provides the base modal functionality.

Basic usage

Minimal example

Code
TypeScript
import { Drawer } from 'vaul'

function BasicDrawer() {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>
        <button className="px-4 py-2 bg-blue-500 text-white rounded-lg">
          Open Drawer
        </button>
      </Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
          <div className="p-4 pb-8">
            <Drawer.Handle className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-gray-300 mb-8" />
            <Drawer.Title className="text-lg font-semibold mb-2">
              Drawer Title
            </Drawer.Title>
            <Drawer.Description className="text-gray-600">
              This is the drawer content. You can place any
              React components here.
            </Drawer.Description>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Full example with Tailwind

Code
TypeScript
import { Drawer } from 'vaul'
import { X } from 'lucide-react'

function FullDrawer() {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>
        <button className="
          px-6 py-3
          bg-gradient-to-r from-purple-500 to-pink-500
          text-white font-medium rounded-xl
          shadow-lg shadow-purple-500/25
          hover:shadow-xl hover:shadow-purple-500/30
          transition-all duration-200
        ">
          Show details
        </button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="
          fixed inset-0
          bg-black/60 backdrop-blur-sm
          animate-in fade-in-0
        " />

        <Drawer.Content className="
          fixed bottom-0 left-0 right-0
          mt-24 flex h-[85%] flex-col
          rounded-t-[24px]
          bg-white dark:bg-gray-900
          shadow-2xl
          animate-in slide-in-from-bottom-1/2
          duration-300
        ">
          {/* Handle */}
          <div className="mx-auto mt-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-gray-300 dark:bg-gray-700" />

          {/* Header */}
          <div className="flex items-center justify-between px-6 py-4 border-b dark:border-gray-800">
            <div>
              <Drawer.Title className="text-xl font-bold dark:text-white">
                Product details
              </Drawer.Title>
              <Drawer.Description className="text-sm text-gray-500 dark:text-gray-400">
                Review product information
              </Drawer.Description>
            </div>
            <Drawer.Close asChild>
              <button className="
                p-2 rounded-full
                hover:bg-gray-100 dark:hover:bg-gray-800
                transition-colors
              ">
                <X className="w-5 h-5 text-gray-500" />
              </button>
            </Drawer.Close>
          </div>

          {/* Scrollable Content */}
          <div className="flex-1 overflow-y-auto p-6">
            <div className="space-y-6">
              <img
                src="/product.jpg"
                alt="Product"
                className="w-full h-64 object-cover rounded-xl"
              />

              <div>
                <h3 className="text-lg font-semibold mb-2 dark:text-white">
                  Description
                </h3>
                <p className="text-gray-600 dark:text-gray-300">
                  Lorem ipsum dolor sit amet, consectetur adipiscing elit.
                  Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
                </p>
              </div>

              <div className="grid grid-cols-2 gap-4">
                <div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-xl">
                  <span className="text-sm text-gray-500 dark:text-gray-400">Price</span>
                  <p className="text-2xl font-bold dark:text-white">$49.99</p>
                </div>
                <div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-xl">
                  <span className="text-sm text-gray-500 dark:text-gray-400">Availability</span>
                  <p className="text-2xl font-bold text-green-600">In stock</p>
                </div>
              </div>
            </div>
          </div>

          {/* Footer */}
          <div className="p-6 border-t dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50">
            <button className="
              w-full py-4
              bg-black dark:bg-white
              text-white dark:text-black
              font-semibold rounded-xl
              hover:bg-gray-900 dark:hover:bg-gray-100
              transition-colors
            ">
              Add to cart
            </button>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Snap points

Basic snap points

Code
TypeScript
import { Drawer } from 'vaul'

function SnapPointsDrawer() {
  return (
    <Drawer.Root snapPoints={[0.25, 0.5, 1]}>
      <Drawer.Trigger asChild>
        <button>Open</button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="
          fixed bottom-0 left-0 right-0
          h-full max-h-[96%]
          bg-white rounded-t-[20px]
        ">
          <div className="p-4">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-4" />

            {/* Drawer stops at 25%, 50%, and 100% of the height */}
            <div className="h-full">
              <h2 className="text-xl font-bold mb-4">Map</h2>
              <div className="h-[400px] bg-gray-100 rounded-xl">
                {/* A map could go here */}
              </div>
            </div>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Controlled snap points

Code
TypeScript
import { Drawer } from 'vaul'
import { useState } from 'react'

type SnapPoint = number | string

function ControlledSnapDrawer() {
  const [snap, setSnap] = useState<SnapPoint>(0.5)
  const snapPoints: SnapPoint[] = ['148px', 0.5, 1]

  return (
    <Drawer.Root
      snapPoints={snapPoints}
      activeSnapPoint={snap}
      setActiveSnapPoint={setSnap}
    >
      <Drawer.Trigger asChild>
        <button className="px-4 py-2 bg-blue-500 text-white rounded-lg">
          Show locations
        </button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="
          fixed bottom-0 left-0 right-0
          h-full max-h-[96%]
          bg-white rounded-t-[20px]
          flex flex-col
        ">
          <div className="p-4 border-b">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-4" />

            <div className="flex gap-2">
              <button
                onClick={() => setSnap('148px')}
                className={`px-3 py-1 rounded-full text-sm ${
                  snap === '148px' ? 'bg-blue-500 text-white' : 'bg-gray-100'
                }`}
              >
                Peek
              </button>
              <button
                onClick={() => setSnap(0.5)}
                className={`px-3 py-1 rounded-full text-sm ${
                  snap === 0.5 ? 'bg-blue-500 text-white' : 'bg-gray-100'
                }`}
              >
                Half
              </button>
              <button
                onClick={() => setSnap(1)}
                className={`px-3 py-1 rounded-full text-sm ${
                  snap === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100'
                }`}
              >
                Full
              </button>
            </div>
          </div>

          <div className="flex-1 overflow-y-auto p-4">
            <h2 className="text-lg font-semibold mb-4">Nearby locations</h2>
            {/* Location list */}
            {[...Array(20)].map((_, i) => (
              <div key={i} className="p-4 border-b">
                <p className="font-medium">Location {i + 1}</p>
                <p className="text-sm text-gray-500">123 Example Street {i + 1}</p>
              </div>
            ))}
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Fade between snap points

Code
TypeScript
function FadeSnapDrawer() {
  const [snap, setSnap] = useState<number | string | null>(0.5)

  return (
    <Drawer.Root
      snapPoints={[0.5, 1]}
      activeSnapPoint={snap}
      setActiveSnapPoint={setSnap}
      fadeFromIndex={0} // Fade starts from the first snap point
    >
      <Drawer.Trigger asChild>
        <button>Open</button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 h-full max-h-[96%] bg-white rounded-t-[20px]">
          <div
            className="p-4 transition-opacity duration-200"
            style={{
              opacity: snap === 1 ? 1 : 0.5,
              pointerEvents: snap === 1 ? 'auto' : 'none'
            }}
          >
            {/* Content visible only when fully expanded */}
            <p>This content is only visible when snap = 1</p>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Nested drawers

Basic nested drawers

Code
TypeScript
import { Drawer } from 'vaul'

function NestedDrawers() {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>
        <button className="px-4 py-2 bg-blue-500 text-white rounded-lg">
          Open first drawer
        </button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
          <div className="p-6">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-6" />

            <Drawer.Title className="text-xl font-bold mb-4">
              Choose a category
            </Drawer.Title>

            <div className="space-y-3">
              {/* Nested drawer */}
              <Drawer.NestedRoot>
                <Drawer.Trigger asChild>
                  <button className="w-full p-4 text-left bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors">
                    <span className="font-medium">Electronics</span>
                    <span className="text-gray-500 block text-sm">
                      Smartphones, laptops, accessories
                    </span>
                  </button>
                </Drawer.Trigger>

                <Drawer.Portal>
                  <Drawer.Overlay className="fixed inset-0 bg-black/40" />
                  <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
                    <div className="p-6">
                      <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-6" />

                      <Drawer.Title className="text-xl font-bold mb-4">
                        Electronics
                      </Drawer.Title>

                      <div className="space-y-2">
                        <button className="w-full p-3 text-left hover:bg-gray-50 rounded-lg">
                          Smartphones
                        </button>
                        <button className="w-full p-3 text-left hover:bg-gray-50 rounded-lg">
                          Laptops
                        </button>
                        <button className="w-full p-3 text-left hover:bg-gray-50 rounded-lg">
                          Accessories
                        </button>
                      </div>
                    </div>
                  </Drawer.Content>
                </Drawer.Portal>
              </Drawer.NestedRoot>

              {/* More categories... */}
              <Drawer.NestedRoot>
                <Drawer.Trigger asChild>
                  <button className="w-full p-4 text-left bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors">
                    <span className="font-medium">Fashion</span>
                    <span className="text-gray-500 block text-sm">
                      Clothing, footwear, accessories
                    </span>
                  </button>
                </Drawer.Trigger>
                {/* ... */}
              </Drawer.NestedRoot>
            </div>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Multi-level navigation

Code
TypeScript
import { Drawer } from 'vaul'
import { ChevronRight, ArrowLeft } from 'lucide-react'

interface MenuItem {
  label: string
  icon?: React.ReactNode
  children?: MenuItem[]
  href?: string
}

const menuItems: MenuItem[] = [
  {
    label: 'Products',
    children: [
      { label: 'New arrivals', href: '/new' },
      { label: 'Bestsellers', href: '/bestsellers' },
      { label: 'Sale', href: '/sale' }
    ]
  },
  {
    label: 'Categories',
    children: [
      {
        label: 'Electronics',
        children: [
          { label: 'Smartphones', href: '/smartphones' },
          { label: 'Laptops', href: '/laptops' }
        ]
      },
      { label: 'Fashion', href: '/fashion' }
    ]
  },
  { label: 'Contact', href: '/contact' }
]

function NavigationItem({ item }: { item: MenuItem }) {
  if (item.children) {
    return (
      <Drawer.NestedRoot>
        <Drawer.Trigger asChild>
          <button className="w-full flex items-center justify-between p-4 hover:bg-gray-50">
            <span>{item.label}</span>
            <ChevronRight className="w-5 h-5 text-gray-400" />
          </button>
        </Drawer.Trigger>

        <Drawer.Portal>
          <Drawer.Overlay className="fixed inset-0 bg-black/40" />
          <Drawer.Content className="fixed bottom-0 left-0 right-0 h-[85%] bg-white rounded-t-[20px]">
            <div className="flex flex-col h-full">
              <div className="flex items-center gap-2 p-4 border-b">
                <Drawer.Close asChild>
                  <button className="p-2 -ml-2 hover:bg-gray-100 rounded-lg">
                    <ArrowLeft className="w-5 h-5" />
                  </button>
                </Drawer.Close>
                <Drawer.Title className="font-semibold">{item.label}</Drawer.Title>
              </div>

              <div className="flex-1 overflow-y-auto">
                {item.children.map((child, i) => (
                  <NavigationItem key={i} item={child} />
                ))}
              </div>
            </div>
          </Drawer.Content>
        </Drawer.Portal>
      </Drawer.NestedRoot>
    )
  }

  return (
    <a href={item.href} className="block p-4 hover:bg-gray-50">
      {item.label}
    </a>
  )
}

function MobileNavigation() {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>
        <button className="p-2">
          <MenuIcon className="w-6 h-6" />
        </button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 h-[85%] bg-white rounded-t-[20px]">
          <div className="flex flex-col h-full">
            <div className="p-4 border-b">
              <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-4" />
              <Drawer.Title className="text-lg font-bold">Menu</Drawer.Title>
            </div>

            <div className="flex-1 overflow-y-auto">
              {menuItems.map((item, i) => (
                <NavigationItem key={i} item={item} />
              ))}
            </div>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Controlled mode

Controlled open state

Code
TypeScript
import { Drawer } from 'vaul'
import { useState } from 'react'

function ControlledDrawer() {
  const [open, setOpen] = useState(false)

  return (
    <>
      {/* External trigger */}
      <button
        onClick={() => setOpen(true)}
        className="px-4 py-2 bg-blue-500 text-white rounded-lg"
      >
        Open from outside
      </button>

      <Drawer.Root open={open} onOpenChange={setOpen}>
        {/* Internal trigger (optional) */}
        <Drawer.Trigger asChild>
          <button className="px-4 py-2 bg-gray-200 rounded-lg ml-2">
            Open
          </button>
        </Drawer.Trigger>

        <Drawer.Portal>
          <Drawer.Overlay className="fixed inset-0 bg-black/40" />
          <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
            <div className="p-6">
              <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-6" />

              <p className="mb-4">Controlled drawer</p>

              <div className="flex gap-2">
                <button
                  onClick={() => setOpen(false)}
                  className="px-4 py-2 bg-red-500 text-white rounded-lg"
                >
                  Close programmatically
                </button>

                <Drawer.Close asChild>
                  <button className="px-4 py-2 bg-gray-200 rounded-lg">
                    Close (Drawer.Close)
                  </button>
                </Drawer.Close>
              </div>
            </div>
          </Drawer.Content>
        </Drawer.Portal>
      </Drawer.Root>
    </>
  )
}

With validation before closing

Code
TypeScript
function DrawerWithValidation() {
  const [open, setOpen] = useState(false)
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)

  const handleOpenChange = (newOpen: boolean) => {
    if (!newOpen && hasUnsavedChanges) {
      const confirmed = window.confirm(
        'You have unsaved changes. Are you sure you want to close?'
      )
      if (!confirmed) return
    }
    setOpen(newOpen)
  }

  return (
    <Drawer.Root open={open} onOpenChange={handleOpenChange}>
      <Drawer.Trigger asChild>
        <button>Open form</button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
          <div className="p-6">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-6" />

            <form onSubmit={(e) => {
              e.preventDefault()
              setHasUnsavedChanges(false)
              setOpen(false)
            }}>
              <input
                type="text"
                onChange={() => setHasUnsavedChanges(true)}
                className="w-full p-2 border rounded mb-4"
                placeholder="Type something..."
              />

              <button
                type="submit"
                className="w-full py-2 bg-blue-500 text-white rounded-lg"
              >
                Save
              </button>
            </form>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Advanced usage

Drawer with a form

Code
TypeScript
import { Drawer } from 'vaul'
import { useState } from 'react'

interface FormData {
  name: string
  email: string
  message: string
}

function ContactDrawer() {
  const [open, setOpen] = useState(false)
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    message: ''
  })

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setIsSubmitting(true)

    try {
      await fetch('/api/contact', {
        method: 'POST',
        body: JSON.stringify(formData)
      })

      setFormData({ name: '', email: '', message: '' })
      setOpen(false)
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <Drawer.Root open={open} onOpenChange={setOpen}>
      <Drawer.Trigger asChild>
        <button className="fixed bottom-6 right-6 w-14 h-14 bg-blue-500 text-white rounded-full shadow-lg flex items-center justify-center">
          <MessageIcon className="w-6 h-6" />
        </button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
          <div className="p-6 max-h-[85vh] overflow-y-auto">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-6" />

            <Drawer.Title className="text-xl font-bold mb-2">
              Contact us
            </Drawer.Title>
            <Drawer.Description className="text-gray-600 mb-6">
              Fill out the form and we will get back to you within 24 hours.
            </Drawer.Description>

            <form onSubmit={handleSubmit} className="space-y-4">
              <div>
                <label className="block text-sm font-medium mb-1">
                  Full name
                </label>
                <input
                  type="text"
                  required
                  value={formData.name}
                  onChange={(e) => setFormData(prev => ({
                    ...prev,
                    name: e.target.value
                  }))}
                  className="w-full p-3 border rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                />
              </div>

              <div>
                <label className="block text-sm font-medium mb-1">
                  Email
                </label>
                <input
                  type="email"
                  required
                  value={formData.email}
                  onChange={(e) => setFormData(prev => ({
                    ...prev,
                    email: e.target.value
                  }))}
                  className="w-full p-3 border rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                />
              </div>

              <div>
                <label className="block text-sm font-medium mb-1">
                  Message
                </label>
                <textarea
                  required
                  rows={4}
                  value={formData.message}
                  onChange={(e) => setFormData(prev => ({
                    ...prev,
                    message: e.target.value
                  }))}
                  className="w-full p-3 border rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
                />
              </div>

              <button
                type="submit"
                disabled={isSubmitting}
                className="w-full py-3 bg-blue-500 text-white font-medium rounded-xl hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
              >
                {isSubmitting ? 'Sending...' : 'Send message'}
              </button>
            </form>
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Drawer with a selection list

Code
TypeScript
interface Option {
  value: string
  label: string
  description?: string
}

interface SelectDrawerProps {
  options: Option[]
  value: string
  onChange: (value: string) => void
  placeholder?: string
}

function SelectDrawer({ options, value, onChange, placeholder = 'Select...' }: SelectDrawerProps) {
  const [open, setOpen] = useState(false)
  const selectedOption = options.find(opt => opt.value === value)

  return (
    <Drawer.Root open={open} onOpenChange={setOpen}>
      <Drawer.Trigger asChild>
        <button className="w-full p-4 border rounded-xl text-left flex items-center justify-between">
          <span className={selectedOption ? 'text-black' : 'text-gray-400'}>
            {selectedOption?.label || placeholder}
          </span>
          <ChevronDown className="w-5 h-5 text-gray-400" />
        </button>
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 max-h-[85vh] bg-white rounded-t-[20px]">
          <div className="p-4 border-b">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-4" />
            <Drawer.Title className="font-semibold">
              {placeholder}
            </Drawer.Title>
          </div>

          <div className="overflow-y-auto max-h-[60vh]">
            {options.map((option) => (
              <button
                key={option.value}
                onClick={() => {
                  onChange(option.value)
                  setOpen(false)
                }}
                className={`w-full p-4 text-left flex items-center justify-between hover:bg-gray-50 ${
                  option.value === value ? 'bg-blue-50' : ''
                }`}
              >
                <div>
                  <p className="font-medium">{option.label}</p>
                  {option.description && (
                    <p className="text-sm text-gray-500">{option.description}</p>
                  )}
                </div>
                {option.value === value && (
                  <Check className="w-5 h-5 text-blue-500" />
                )}
              </button>
            ))}
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Responsive drawer/dialog

Code
TypeScript
import { Drawer } from 'vaul'
import * as Dialog from '@radix-ui/react-dialog'
import { useMediaQuery } from '@/hooks/useMediaQuery'

interface ResponsiveDrawerProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  trigger: React.ReactNode
  title: string
  children: React.ReactNode
}

function ResponsiveDrawer({
  open,
  onOpenChange,
  trigger,
  title,
  children
}: ResponsiveDrawerProps) {
  const isDesktop = useMediaQuery('(min-width: 768px)')

  if (isDesktop) {
    // On desktop we use Dialog
    return (
      <Dialog.Root open={open} onOpenChange={onOpenChange}>
        <Dialog.Trigger asChild>
          {trigger}
        </Dialog.Trigger>

        <Dialog.Portal>
          <Dialog.Overlay className="fixed inset-0 bg-black/40" />
          <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-xl p-6 w-full max-w-md">
            <Dialog.Title className="text-xl font-bold mb-4">
              {title}
            </Dialog.Title>
            {children}
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    )
  }

  // On mobile we use Drawer
  return (
    <Drawer.Root open={open} onOpenChange={onOpenChange}>
      <Drawer.Trigger asChild>
        {trigger}
      </Drawer.Trigger>

      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-[20px]">
          <div className="p-6">
            <Drawer.Handle className="mx-auto w-12 h-1.5 bg-gray-300 rounded-full mb-6" />
            <Drawer.Title className="text-xl font-bold mb-4">
              {title}
            </Drawer.Title>
            {children}
          </div>
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  )
}

Integration with shadcn/ui

shadcn/ui includes a Drawer component that used to be built on Vaul and now composes Base UI primitives. The composition stayed the same, while passing an element to the trigger changed: instead of asChild you use the render prop. The command line tool changed name too, since the shadcn-ui package is marked unsupported and shadcn replaced it.

Code
Bash
npx shadcn@latest add drawer
Code
TypeScript
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"

function ShadcnDrawer() {
  return (
    <Drawer>
      <DrawerTrigger render={<Button variant="outline" />}>
        Open Drawer
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Title</DrawerTitle>
            <DrawerDescription>
              Drawer description
            </DrawerDescription>
          </DrawerHeader>

          <div className="p-4">
            {/* Content */}
          </div>

          <DrawerFooter>
            <Button>Save</Button>
            <DrawerClose render={<Button variant="outline" />}>
              Cancel
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Props and API

Drawer.Root

PropTypeDefaultDescription
openboolean-Controlled open state
onOpenChange(open: boolean) => void-Callback on state change
snapPoints(number | string)[]-Snap points (0-1 or px)
activeSnapPointnumber | string | null-Active snap point
setActiveSnapPoint(snap) => void-Setter for snap point
fadeFromIndexnumber-Index from which fade begins
modalbooleantrueWhether it blocks background interaction
dismissiblebooleantrueWhether it can be closed by gesture
shouldScaleBackgroundbooleanfalseBackground scaling (iOS effect)
direction'bottom' | 'top' | 'left' | 'right''bottom'Slide direction

A note on the second to last row: the iOS style background scaling is off by default and has to be switched on explicitly. It also needs the application wrapped in an element carrying the data-vaul-drawer-wrapper attribute, since without it the component finds nothing to scale and silently does nothing.

Drawer.Content

PropTypeDefaultDescription
onPointerDownOutside(e) => void-Callback on click outside
onEscapeKeyDown(e) => void-Callback on Escape
onInteractOutside(e) => void-Callback on interaction outside

Drawer.Handle

An optional "handle" element for dragging. Style it however you like.

Drawer.Trigger / Drawer.Close

Use with asChild to pass props to the child element.

Pricing

LicenseCost
MIT LicenseFree
Commercial useFree
ModificationsAllowed

Vaul is completely free and open source under the MIT license.

FAQ - frequently asked questions

Does Vaul work on desktop?

Yes, Vaul works great on desktop. Mouse gestures (drag) are supported, and the component can also be controlled programmatically or closed with the Escape key.

How do I prevent closing by gesture?

Code
TypeScript
<Drawer.Root dismissible={false}>
  {/* Drawer won't close by dragging */}
</Drawer.Root>

Can I use Vaul without Tailwind?

Yes, Vaul is unstyled. You can use any CSS system:

Code
TypeScript
<Drawer.Content style={{
  position: 'fixed',
  bottom: 0,
  left: 0,
  right: 0,
  backgroundColor: 'white',
  borderTopLeftRadius: 20,
  borderTopRightRadius: 20
}}>

How do I add an opening animation?

Vaul has built-in spring animations. You can add extra ones with Tailwind:

Code
TypeScript
<Drawer.Content className="
  animate-in slide-in-from-bottom duration-300
">

Does Vaul support SSR?

Yes, Vaul works with Next.js App Router, Pages Router, Remix, and other frameworks with SSR. The portal is only rendered on the client side.

How do I handle long content?

Code
TypeScript
<Drawer.Content className="fixed bottom-0 left-0 right-0 h-[85vh] flex flex-col">
  <div className="flex-shrink-0 p-4 border-b">
    <Drawer.Handle />
  </div>
  <div className="flex-1 overflow-y-auto p-4">
    {/* Scrollable content */}
  </div>
</Drawer.Content>

How do I make a drawer from the top or the side?

Code
TypeScript
<Drawer.Root direction="top">
  {/* Drawer slides in from the top */}
</Drawer.Root>

<Drawer.Root direction="right">
  {/* Drawer slides in from the right (sidebar) */}
</Drawer.Root>

Can I nest more than two levels of drawers?

Technically yes, each nested level creates another. Practically it is worth stopping at two, because a third means the user has three layers to close and loses track of where they are.

Is the project still being developed?

No. The author wrote in the repository readme that he lacks the time and motivation to maintain it and might return at some point but not in the near future. The last code changes date from mid 2025.

What to replace it with

Since the project has stalled, the exits are worth knowing along with what each costs.

The simplest is the drawer component from the set you already use. If you build on a ready made component library, it probably ships its own, maintained alongside the rest. Migration reduces to renaming components and checking the behaviour matches your expectations.

The second is Base UI, an unstyled behaviour layer that released a drawer component. It is currently the closest match in philosophy: you get behaviour and accessibility while assembling the appearance yourself. That is exactly what shadcn/ui switched to.

The third is writing your own on top of a dialog primitive and an animation library. It sounds reasonable and is a trap, because the sliding itself takes an hour while reproducing gesture behaviour with momentum and a velocity threshold takes days and still turns out worse.

The fourth, often the most sensible, is leaving things alone. If the component works in your project and you are not changing React versions soon, the lack of updates is not a problem. A library with no external dependencies beyond Radix UI will not age overnight, and replacing working code without cause carries its own cost.

It is worth recording that status in project documentation or in a comment beside the import, though. A year from now nobody will remember this dependency is unattended, and that is precisely the information you want at the moment something breaks.

The source and project status are in the Vaul repository, and demonstrations on the project site.