Vaul - Drawer dla React
Czym jest Vaul?
Vaul to unstyled drawer (szuflada/panel) komponent dla React stworzony przez Emila Kowalskiego - tego samego twórcę, który dał nam Sonner (toast notifications). Vaul jest wzorowany na natywnych drawerach iOS, oferując płynne gesty dotykowe, snap points (punkty zatrzymania) i wsparcie dla nested drawers (zagnieżdżonych szuflad).
Nazwa "Vaul" pochodzi z języka walijskiego i oznacza "szafę" lub "skarbiec" - co idealnie oddaje ideę komponentu, który można "wysunąć" z krawędzi ekranu, aby odsłonić zawartość.
Filozofia projektowa
Vaul został stworzony z kilkoma kluczowymi założeniami:
- Unstyled - Zero domyślnych styli, pełna kontrola nad wyglądem
- Accessible - Zgodność z WAI-ARIA, obsługa klawiatury, focus management
- Mobile-first - Zoptymalizowany dla urządzeń dotykowych
- Composable - API oparte na kompozycji (Radix-style)
- Performant - Płynne animacje bez lag'ów nawet na słabszych urządzeniach
Dlaczego drawer zamiast modal?
Drawery (bottom sheets) stały się standardem w aplikacjach mobilnych, ponieważ:
- Ergonomia - Łatwiejszy dostęp kciukiem na dużych telefonach
- Kontekst - Użytkownik widzi część poprzedniego ekranu
- Naturalność - Gest wysuwania jest intuicyjny
- Progresywne ujawnianie - Snap points pozwalają pokazać najpierw preview
Vaul przenosi te zalety do aplikacji webowych, zachowując natywne feel iOS/Android.
Dlaczego Vaul?
1. Natywne gesty jak na iOS
Vaul implementuje dokładnie takie same gesty jak natywny iOS:
- Przeciągnięcie w dół zamyka drawer
- Szybki swipe (velocity-based) natychmiast zamyka
- Powolne przeciąganie pozwala na cofnięcie się
- Zatrzymanie na snap pointach z animacją spring
2. Snap Points
Snap points to punkty, w których drawer "zatrzymuje się". Pozwalają na progresywne ujawnianie treści:
┌─────────────────────┐
│ │ ← Full (100%)
│ │
│ │
├─────────────────────┤ ← Half (50%)
│ Drawer │
│ Content │
├─────────────────────┤ ← Peek (25%)
│ Handle │
└─────────────────────┘3. Nested Drawers
Vaul wspiera zagnieżdżone drawery - możesz otworzyć drawer z wnętrza innego drawera. Idealne dla wieloetapowych formularzy lub nawigacji hierarchicznej.
4. Pełna dostępność (a11y)
- Zarządzanie fokusem (focus trap)
- Obsługa Escape do zamykania
- Poprawne role ARIA
- Wsparcie dla screen readers
- Redukcja ruchu dla użytkowników z vestibular disorders
5. Kompozycyjne 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 zależności wizualnych
Vaul nie narzuca żadnych styli - styluj jak chcesz:
- Tailwind CSS
- CSS Modules
- styled-components
- Vanilla CSS
- Emotion
Instalacja
# npm
npm install vaul
# yarn
yarn add vaul
# pnpm
pnpm add vaul
# bun
bun add vaulVaul ma tylko jedną zależność: @radix-ui/react-dialog, która dostarcza bazową funkcjonalność modal.
Podstawowe użycie
Minimalny przykład
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">
Otwórz 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">
Tytuł Drawera
</Drawer.Title>
<Drawer.Description className="text-gray-600">
To jest zawartość drawera. Możesz tutaj umieścić
dowolne komponenty React.
</Drawer.Description>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Pełny przykład z 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
">
Pokaż szczegóły
</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">
Szczegóły produktu
</Drawer.Title>
<Drawer.Description className="text-sm text-gray-500 dark:text-gray-400">
Przejrzyj informacje o produkcie
</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="Produkt"
className="w-full h-64 object-cover rounded-xl"
/>
<div>
<h3 className="text-lg font-semibold mb-2 dark:text-white">
Opis
</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">Cena</span>
<p className="text-2xl font-bold dark:text-white">199 zł</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">Dostępność</span>
<p className="text-2xl font-bold text-green-600">W magazynie</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
">
Dodaj do koszyka
</button>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Snap Points
Podstawowe snap points
import { Drawer } from 'vaul'
function SnapPointsDrawer() {
return (
<Drawer.Root snapPoints={[0.25, 0.5, 1]}>
<Drawer.Trigger asChild>
<button>Otwórz</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 zatrzymuje się na 25%, 50% i 100% wysokości */}
<div className="h-full">
<h2 className="text-xl font-bold mb-4">Mapa</h2>
<div className="h-[400px] bg-gray-100 rounded-xl">
{/* Tu może być mapa */}
</div>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Kontrolowane 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">
Pokaż lokalizacje
</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">Najbliższe lokalizacje</h2>
{/* Lista lokalizacji */}
{[...Array(20)].map((_, i) => (
<div key={i} className="p-4 border-b">
<p className="font-medium">Lokalizacja {i + 1}</p>
<p className="text-sm text-gray-500">ul. Przykładowa {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 rozpoczyna się od pierwszego snap pointa
>
<Drawer.Trigger asChild>
<button>Otwórz</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'
}}
>
{/* Zawartość widoczna tylko przy pełnym rozwinięciu */}
<p>Ta zawartość jest widoczna tylko przy snap = 1</p>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Nested Drawers
Podstawowe 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">
Otwórz pierwszy 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">
Wybierz kategorię
</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">Elektronika</span>
<span className="text-gray-500 block text-sm">
Smartfony, laptopy, akcesoria
</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">
Elektronika
</Drawer.Title>
<div className="space-y-2">
<button className="w-full p-3 text-left hover:bg-gray-50 rounded-lg">
Smartfony
</button>
<button className="w-full p-3 text-left hover:bg-gray-50 rounded-lg">
Laptopy
</button>
<button className="w-full p-3 text-left hover:bg-gray-50 rounded-lg">
Akcesoria
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.NestedRoot>
{/* Kolejne kategorie... */}
<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">Moda</span>
<span className="text-gray-500 block text-sm">
Odzież, obuwie, akcesoria
</span>
</button>
</Drawer.Trigger>
{/* ... */}
</Drawer.NestedRoot>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Wielopoziomowa nawigacja
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: 'Produkty',
children: [
{ label: 'Nowe', href: '/nowe' },
{ label: 'Bestsellery', href: '/bestsellery' },
{ label: 'Wyprzedaż', href: '/wyprzedaz' }
]
},
{
label: 'Kategorie',
children: [
{
label: 'Elektronika',
children: [
{ label: 'Smartfony', href: '/smartfony' },
{ label: 'Laptopy', href: '/laptopy' }
]
},
{ label: 'Moda', href: '/moda' }
]
},
{ label: 'Kontakt', href: '/kontakt' }
]
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
Kontrolowany stan otwarcia
import { Drawer } from 'vaul'
import { useState } from 'react'
function ControlledDrawer() {
const [open, setOpen] = useState(false)
return (
<>
{/* Trigger zewnętrzny */}
<button
onClick={() => setOpen(true)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg"
>
Otwórz z zewnątrz
</button>
<Drawer.Root open={open} onOpenChange={setOpen}>
{/* Trigger wewnętrzny (opcjonalny) */}
<Drawer.Trigger asChild>
<button className="px-4 py-2 bg-gray-200 rounded-lg ml-2">
Otwórz
</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">Kontrolowany drawer</p>
<div className="flex gap-2">
<button
onClick={() => setOpen(false)}
className="px-4 py-2 bg-red-500 text-white rounded-lg"
>
Zamknij programowo
</button>
<Drawer.Close asChild>
<button className="px-4 py-2 bg-gray-200 rounded-lg">
Zamknij (Drawer.Close)
</button>
</Drawer.Close>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</>
)
}Z walidacją przed zamknięciem
function DrawerWithValidation() {
const [open, setOpen] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && hasUnsavedChanges) {
const confirmed = window.confirm(
'Masz niezapisane zmiany. Czy na pewno chcesz zamknąć?'
)
if (!confirmed) return
}
setOpen(newOpen)
}
return (
<Drawer.Root open={open} onOpenChange={handleOpenChange}>
<Drawer.Trigger asChild>
<button>Otwórz formularz</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="Wpisz coś..."
/>
<button
type="submit"
className="w-full py-2 bg-blue-500 text-white rounded-lg"
>
Zapisz
</button>
</form>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Zaawansowane użycie
Drawer z formularzem
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">
Skontaktuj się z nami
</Drawer.Title>
<Drawer.Description className="text-gray-600 mb-6">
Wypełnij formularz, a odezwiemy się w ciągu 24 godzin.
</Drawer.Description>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
Imię i nazwisko
</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">
Wiadomość
</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 ? 'Wysyłanie...' : 'Wyślij wiadomość'}
</button>
</form>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)
}Drawer z listą wyboru
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 = 'Wybierz...' }: 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) {
// Na desktopie używamy 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>
)
}
// Na mobile używamy 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>
)
}Integracja z shadcn/ui
shadcn/ui zawiera komponent Drawer oparty na Vaul:
npx shadcn-ui@latest add drawer// Użycie shadcn drawer
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
function ShadcnDrawer() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Otwórz Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Tytuł</DrawerTitle>
<DrawerDescription>
Opis drawera
</DrawerDescription>
</DrawerHeader>
<div className="p-4">
{/* Zawartość */}
</div>
<DrawerFooter>
<Button>Zapisz</Button>
<DrawerClose asChild>
<Button variant="outline">Anuluj</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Props i API
Drawer.Root
| Prop | Typ | Domyślne | Opis |
|---|---|---|---|
open | boolean | - | Kontrolowany stan otwarcia |
onOpenChange | (open: boolean) => void | - | Callback przy zmianie stanu |
snapPoints | (number | string)[] | - | Punkty zatrzymania (0-1 lub px) |
activeSnapPoint | number | string | null | - | Aktywny snap point |
setActiveSnapPoint | (snap) => void | - | Setter dla snap point |
fadeFromIndex | number | - | Indeks od którego zaczyna się fade |
modal | boolean | true | Czy blokuje interakcję z tłem |
dismissible | boolean | true | Czy można zamknąć gestem |
shouldScaleBackground | boolean | true | Skalowanie tła (efekt iOS) |
direction | 'bottom' | 'top' | 'left' | 'right' | 'bottom' | Kierunek wysuwania |
Drawer.Content
| Prop | Typ | Domyślne | Opis |
|---|---|---|---|
onPointerDownOutside | (e) => void | - | Callback przy kliknięciu poza |
onEscapeKeyDown | (e) => void | - | Callback przy Escape |
onInteractOutside | (e) => void | - | Callback przy interakcji poza |
Drawer.Handle
Opcjonalny element "uchwytu" do przeciągania. Styluj dowolnie.
Drawer.Trigger / Drawer.Close
Używaj z asChild aby przekazać props do dziecka.
Cennik
| Licencja | Koszt |
|---|---|
| MIT License | Darmowy |
| Komercyjne użycie | Darmowy |
| Modyfikacje | Dozwolone |
Vaul jest w pełni darmowy i open source na licencji MIT.
FAQ - Najczęściej zadawane pytania
Czy Vaul działa na desktopie?
Tak, Vaul działa świetnie na desktopie. Gesty myszki (drag) są wspierane, a komponent można też kontrolować programowo lub zamykać klawiszem Escape.
Jak zablokować zamykanie przez gest?
<Drawer.Root dismissible={false}>
{/* Drawer nie zamknie się przez przeciągnięcie */}
</Drawer.Root>Czy mogę użyć Vaul bez Tailwind?
Tak, Vaul jest unstyled. Możesz użyć dowolnego systemu CSS:
<Drawer.Content style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20
}}>Jak dodać animację przy otwieraniu?
Vaul ma wbudowane animacje spring. Możesz dodać dodatkowe z Tailwind:
<Drawer.Content className="
animate-in slide-in-from-bottom duration-300
">Czy Vaul wspiera SSR?
Tak, Vaul działa z Next.js App Router, Pages Router, Remix i innymi frameworkami z SSR. Portal jest renderowany tylko po stronie klienta.
Jak obsłużyć długą zawartość?
<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">
{/* Scrollowalna zawartość */}
</div>
</Drawer.Content>Jak zrobić drawer od góry lub z boku?
<Drawer.Root direction="top">
{/* Drawer wysuwa się od góry */}
</Drawer.Root>
<Drawer.Root direction="right">
{/* Drawer wysuwa się od prawej (sidebar) */}
</Drawer.Root>Czy mogę zagnieździć więcej niż 2 poziomy drawerów?
Tak, nie ma ograniczenia głębokości. Każdy Drawer.NestedRoot tworzy nowy poziom.
Vaul - drawer for React
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.
The name "Vaul" comes from the Welsh language and means "wardrobe" or "vault" - which perfectly captures the idea of a component that can be "pulled out" from the edge of the screen to reveal its contents.
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 built on top of Vaul:
npx shadcn-ui@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 asChild>
<Button variant="outline">Open Drawer</Button>
</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 asChild>
<Button variant="outline">Cancel</Button>
</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 | true | Background scaling (iOS effect) |
direction | 'bottom' | 'top' | 'left' | 'right' | 'bottom' | Slide direction |
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 2 levels of drawers?
Yes, there is no depth limit. Each Drawer.NestedRoot creates a new level.