Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik21 min czytania

Vaul, komponent szuflady w stylu iOS dla Reacta

Vaul to komponent szuflady dla Reacta z gestami i punktami zatrzymania. Autor ogłosił brak utrzymania, więc sprawdź alternatywy przed użyciem.

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:

  1. Unstyled - Zero domyślnych styli, pełna kontrola nad wyglądem
  2. Accessible - Zgodność z WAI-ARIA, obsługa klawiatury, focus management
  3. Mobile-first - Zoptymalizowany dla urządzeń dotykowych
  4. Composable - API oparte na kompozycji (Radix-style)
  5. 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:

Code
TEXT
┌─────────────────────┐
│                     │ ← 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)

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

6. Zero zależności wizualnych

Vaul nie narzuca żadnych styli - styluj jak chcesz:

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

Instalacja

Code
Bash
# npm
npm install vaul

# yarn
yarn add vaul

# pnpm
pnpm add vaul

# bun
bun add vaul

Vaul ma tylko jedną zależność: @radix-ui/react-dialog, która dostarcza bazową funkcjonalność modal.

Podstawowe użycie

Minimalny przykład

Code
TypeScript
import { Drawer } from 'vaul'

function BasicDrawer() {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>
        <button className="px-4 py-2 bg-blue-500 text-white rounded-lg">
          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

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

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

Code
TypeScript
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

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

type SnapPoint = number | string

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

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

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

  return (
    <Drawer.Root
      snapPoints={[0.5, 1]}
      activeSnapPoint={snap}
      setActiveSnapPoint={setSnap}
      fadeFromIndex={0} // Fade 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

Code
TypeScript
import { Drawer } from 'vaul'

function NestedDrawers() {
  return (
    <Drawer.Root>
      <Drawer.Trigger asChild>
        <button className="px-4 py-2 bg-blue-500 text-white rounded-lg">
          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

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

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

const menuItems: MenuItem[] = [
  {
    label: '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

Code
TypeScript
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

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

  const handleOpenChange = (newOpen: boolean) => {
    if (!newOpen && hasUnsavedChanges) {
      const confirmed = window.confirm(
        '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

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

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

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

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

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

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

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

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

            <Drawer.Title className="text-xl font-bold mb-2">
              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

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

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

function SelectDrawer({ options, value, onChange, placeholder = '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

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

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

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

  if (isDesktop) {
    // 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.

Code
Bash
npx shadcn@latest add drawer
Code
TypeScript
// 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

PropTypDomyślneOpis
openboolean-Kontrolowany stan otwarcia
onOpenChange(open: boolean) => void-Callback przy zmianie stanu
snapPoints(number | string)[]-Punkty zatrzymania (0-1 lub px)
activeSnapPointnumber | string | null-Aktywny snap point
setActiveSnapPoint(snap) => void-Setter dla snap point
fadeFromIndexnumber-Indeks od którego zaczyna się fade
modalbooleantrueCzy blokuje interakcję z tłem
dismissiblebooleantrueCzy można zamknąć gestem
shouldScaleBackgroundbooleanfalseSkalowanie 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

PropTypDomyślneOpis
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

LicencjaKoszt
MIT LicenseDarmowy
Komercyjne użycieDarmowy
ModyfikacjeDozwolone

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?

Code
TypeScript
<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:

Code
TypeScript
<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:

Code
TypeScript
<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ść?

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

Jak zrobić drawer od góry lub z boku?

Code
TypeScript
<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.