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:
- Unstyled - Zero default styles, full control over appearance
- Accessible - WAI-ARIA compliant, keyboard support, focus management
- Mobile-first - Optimized for touch devices
- Composable - Composition-based API (Radix-style)
- 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:
βββββββββββββββββββββββ
β β β 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)
<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
# npm
npm install vaul
# yarn
yarn add vaul
# pnpm
pnpm add vaul
# bun
bun add vaulVaul has only one dependency: @radix-ui/react-dialog, which provides the base modal functionality.
Basic usage
Minimal example
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
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
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
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
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
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
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
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
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
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
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
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.
npx shadcn@latest add drawerimport {
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
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controlled open state |
onOpenChange | (open: boolean) => void | - | Callback on state change |
snapPoints | (number | string)[] | - | Snap points (0-1 or px) |
activeSnapPoint | number | string | null | - | Active snap point |
setActiveSnapPoint | (snap) => void | - | Setter for snap point |
fadeFromIndex | number | - | Index from which fade begins |
modal | boolean | true | Whether it blocks background interaction |
dismissible | boolean | true | Whether it can be closed by gesture |
shouldScaleBackground | boolean | false | Background 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
| Prop | Type | Default | Description |
|---|---|---|---|
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
| License | Cost |
|---|---|
| MIT License | Free |
| Commercial use | Free |
| Modifications | Allowed |
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?
<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:
<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:
<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?
<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?
<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.