Vaul, komponent szuflady w stylu iOS dla Reacta
Vaul to komponent szuflady wysuwanej od krawędzi ekranu, zaprojektowany tak, żeby przypominał zachowaniem panele znane z systemu iOS: przeciąganie palcem, opór przy krawędzi, zatrzymywanie się na ustalonych wysokościach. Jest wydawany na licencji MIT, w wersji 1.1.2 z grudnia 2024 roku, a pod spodem korzysta z okna dialogowego z Radix UI.
Stan projektu, czyli rzecz do przeczytania przed instalacją
To jest najważniejsza informacja w tym tekście i nie znajdziesz jej w większości materiałów o tej bibliotece, bo pochodzą sprzed tej zmiany.
Autor ogłosił, że projekt nie jest utrzymywany. W pliku opisującym repozytorium napisał wprost, że to projekt hobbystyczny, na który obecnie nie ma czasu ani motywacji, i że być może wróci do niego kiedyś, ale nie w najbliższym czasie. Ostatnia zmiana w repozytorium pochodzi z 3 października 2025 roku i była właśnie tym wyjaśnieniem. Ostatnie zmiany w kodzie są jeszcze starsze, z lipca 2025, a otwartych zgłoszeń jest ponad sto pięćdziesiąt.
Nie oznacza to, że biblioteki nie da się używać. Kod działa, jest niewielki, a problem, który rozwiązuje, nie zmienia się co miesiąc. Oznacza natomiast, że nie licz na poprawki błędów ani na zgodność z przyszłymi wersjami Reacta, a każdy napotkany problem będziesz naprawiał sam albo obchodził.
Konsekwencje widać już w ekosystemie. Base UI wydało własny komponent szuflady, a shadcn/ui przestawiło swój komponent na to rozwiązanie. Powstał też projekt pochodny łączący sposób działania znany z Vaula z implementacją opartą o Base UI.
Praktyczna rekomendacja jest więc taka: przy nowym projekcie sprawdź najpierw, czy zestaw komponentów, którego używasz, ma już własną szufladę. Przy projekcie istniejącym nie ma pośpiechu, ale warto zaplanować wymianę przy najbliższej większej aktualizacji Reacta, bo to najbardziej prawdopodobny moment, w którym coś przestanie działać.
Co ten komponent robi dobrze
Warto opisać, dlaczego ta biblioteka w ogóle zdobyła popularność, bo to tłumaczy, czego szukać w zamienniku.
Podstawą jest wierne odwzorowanie zachowania znanego z telefonu. Szuflada podąża za palcem, ma bezwładność, przy szybkim ruchu zamyka się nawet przed dojściem do końca, a przy powolnym wraca na miejsce. Te drobiazgi decydują o tym, czy interfejs sprawia wrażenie natywnego, a napisanie ich samodzielnie zajmuje więcej czasu, niż zakłada każdy, kto tego nie robił.
Druga rzecz to punkty zatrzymania. Szuflada może mieć kilka ustalonych wysokości, między którymi przeskakuje, co pozwala zbudować panel częściowo widoczny u dołu ekranu, rozwijany do połowy i do pełnego widoku. To wzorzec znany z map i odtwarzarek muzyki.
Trzecia to warstwa dostępności odziedziczona z okna dialogowego Radix UI: pułapka fokusa, zamykanie klawiszem ucieczki, poprawne atrybuty roli i blokada przewijania tła. Tego nie trzeba było pisać, bo biblioteka opakowuje gotowe rozwiązanie.
Czwarta to brak własnych stylów. Komponent dostarcza zachowanie i strukturę, a wygląd składasz sam, najczęściej klasami Tailwind CSS. Dzięki temu wpasowuje się w dowolny system projektowy zamiast go narzucać.
Kiedy szuflada, a kiedy zwykłe okno dialogowe
To rozróżnienie decyduje o tym, czy komponent w ogóle jest potrzebny, a bywa pomijane.
Szuflada wygrywa na telefonie i przy treści, którą użytkownik przegląda, a nie tylko potwierdza. Wysuwana od dołu jest w zasięgu kciuka, można ją zamknąć gestem bez celowania w mały przycisk i nie zasłania całego ekranu, więc kontekst pozostaje widoczny. Filtry na liście produktów, szczegóły punktu na mapie, wybór opcji z długiej listy.
Zwykłe okno dialogowe wygrywa na komputerze i przy decyzjach wymagających uwagi. Potwierdzenie usunięcia, formularz płatności, komunikat blokujący dalszą pracę. Tu przerwanie kontekstu jest zamierzone, a gest przeciągnięcia niczego nie wnosi, bo użytkownik i tak trzyma mysz.
Rozwiązaniem, które przyjęło się w praktyce, jest użycie obu naraz i przełączanie w zależności od szerokości ekranu: szuflada poniżej progu, okno powyżej. Kosztem jest utrzymywanie dwóch wariantów tej samej treści, więc warto wydzielić zawartość do osobnego komponentu i wstawiać ją do jednego lub drugiego opakowania.
Przy takim przełączaniu warto pamiętać o jednej pułapce technicznej. Sprawdzenie szerokości okna działa dopiero w przeglądarce, więc przy renderowaniu po stronie serwera pierwszy wynik jest zgadywany i po dotarciu do użytkownika może się zmienić. Objawia się to mignięciem: przez ułamek sekundy widać jeden wariant, potem drugi. Rozwiązaniem jest albo oparcie przełączania o zapytania medialne w stylach zamiast o kod, albo świadome opóźnienie renderowania tego fragmentu do momentu, w którym szerokość jest znana.
Druga pułapka dotyczy klawiatury ekranowej. Szuflada z polem tekstowym na telefonie zachowuje się różnie w zależności od systemu: klawiatura potrafi ją przykryć, przesunąć albo zmienić wysokość widocznego obszaru. Warto to sprawdzić na prawdziwym urządzeniu, bo emulator w narzędziach przeglądarki tego zachowania nie odtwarza.
Czym jest Vaul?
Vaul to komponent szuflady dla Reacta, wydany bez stylów, autorstwa Emila Kowalskiego, tego samego, który stworzył Sonner do powiadomień. Wzoruje się na szufladach znanych z systemu iOS: obsługuje płynne gesty dotykowe, punkty zatrzymania oraz szuflady zagnieżdżone jedna w drugiej.
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 internetowych, zachowując wrażenie znane z systemów mobilnych.
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, który był kiedyś oparty na Vaulu, a obecnie składa się z prymitywów Base UI. Kompozycja została ta sama, zmieniło się natomiast przekazywanie elementu do wyzwalacza: zamiast asChild używa się właściwości render. Zmieniła się też nazwa narzędzia wiersza poleceń, bo pakiet shadcn-ui jest oznaczony jako niewspierany i zastąpił go shadcn.
npx shadcn@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 render={<Button variant="outline" />}>
Otwórz Drawer
</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 render={<Button variant="outline" />}>
Anuluj
</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 | false | Skalowanie tła (efekt iOS) |
direction | 'bottom' | 'top' | 'left' | 'right' | 'bottom' | Kierunek wysuwania |
Uwaga do przedostatniego wiersza: efekt skalowania tła znany z iOS jest domyślnie wyłączony i trzeba go włączyć jawnie. Wymaga też opakowania aplikacji elementem z atrybutem data-vaul-drawer-wrapper, bo bez niego komponent nie znajduje czego skalować i po cichu nic nie robi.
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ż dwa poziomy szuflad?
Technicznie tak, każdy zagnieżdżony poziom tworzy kolejny. Praktycznie warto się przy dwóch zatrzymać, bo trzeci poziom oznacza, że użytkownik ma nad sobą trzy warstwy do zamknięcia i przestaje wiedzieć, gdzie jest.
Czy projekt jest nadal rozwijany?
Nie. Autor napisał w opisie repozytorium, że nie ma czasu ani motywacji na utrzymanie i że być może wróci do tego kiedyś, ale nie w najbliższym czasie. Ostatnie zmiany w kodzie pochodzą z połowy 2025 roku.
Czym to zastąpić
Skoro projekt stoi, warto znać drogi wyjścia i wiedzieć, ile kosztuje każda z nich.
Najprostsza to komponent szuflady z zestawu, którego już używasz. Jeśli budujesz na gotowej bibliotece komponentów, prawdopodobnie ma ona własne rozwiązanie, utrzymywane razem z resztą. Koszt migracji sprowadza się do zmiany nazw komponentów i sprawdzenia, czy zachowanie odpowiada Twoim oczekiwaniom.
Druga to Base UI, warstwa zachowań bez własnych stylów, która wydała komponent szuflady. Jest to obecnie najbliższy odpowiednik pod względem filozofii: dostajesz zachowanie i dostępność, a wygląd składasz sam. Właśnie na to przestawiło się shadcn/ui.
Trzecia to napisanie własnego rozwiązania na bazie okna dialogowego i biblioteki animacji. Brzmi rozsądnie i bywa pułapką, bo samo wysuwanie napiszesz w godzinę, a odtworzenie zachowania gestów z bezwładnością i progiem prędkości zajmie kilka dni i i tak wypadnie gorzej.
Czwarta, często najrozsądniejsza, to zostawienie rzeczy w spokoju. Jeśli komponent działa w Twoim projekcie i nie planujesz w najbliższym czasie zmiany wersji Reacta, brak aktualizacji nie jest problemem. Biblioteka bez zależności zewnętrznych poza Radix UI nie zestarzeje się z dnia na dzień, a wymiana działającego kodu bez powodu ma własny koszt.
Warto natomiast odnotować ten stan w dokumentacji projektu albo w komentarzu przy imporcie. Za rok nikt nie będzie pamiętał, że ta zależność jest bez opieki, a to informacja, która przydaje się dokładnie w momencie, gdy coś przestaje działać.
Kod źródłowy i stan projektu sprawdzisz w repozytorium Vaul, a demonstracje na stronie projektu.