Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds
Volver a colecciones
Guide31 min read

Vaul

Vaul is a React drawer component inspired by iOS with smooth gestures, snap points, and nested drawers. Unstyled, accessible, and production-ready.

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:

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

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 oparty na Vaul:

Code
Bash
npx shadcn-ui@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 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

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
shouldScaleBackgroundbooleantrueSkalowanie tła (efekt iOS)
direction'bottom' | 'top' | 'left' | 'right''bottom'Kierunek wysuwania

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ż 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:

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

Why drawer instead of modal?

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

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

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

Why Vaul?

1. Native gestures like iOS

Vaul implements exactly the same gestures as native iOS:

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

2. Snap points

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

Code
TEXT
┌─────────────────────┐
│                     │ ← Full (100%)
│                     │
│                     │
├─────────────────────┤ ← Half (50%)
│     Drawer          │
│     Content         │
├─────────────────────┤ ← Peek (25%)
│     Handle          │
└─────────────────────┘

3. Nested drawers

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

4. Full accessibility (a11y)

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

5. Composable API (Radix-style)

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

6. Zero visual dependencies

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

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

Installation

Code
Bash
# npm
npm install vaul

# yarn
yarn add vaul

# pnpm
pnpm add vaul

# bun
bun add vaul

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

Basic usage

Minimal example

Code
TypeScript
import { Drawer } from 'vaul'

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

Full example with Tailwind

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

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

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

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

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

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

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

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

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

Snap points

Basic snap points

Code
TypeScript
import { Drawer } from 'vaul'

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

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

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

Controlled snap points

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

type SnapPoint = number | string

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

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

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

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

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

Fade between snap points

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

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

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

Nested drawers

Basic nested drawers

Code
TypeScript
import { Drawer } from 'vaul'

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

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

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

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

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

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

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

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

Multi-level navigation

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

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

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

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

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

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

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

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

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

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

Controlled mode

Controlled open state

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

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

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

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

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

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

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

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

With validation before closing

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

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

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

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

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

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

Advanced usage

Drawer with a form

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

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

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

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

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

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

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

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

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

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

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

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

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

Drawer with a selection list

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

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

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

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

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

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

Responsive drawer/dialog

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

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

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

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

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

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

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

Integration with shadcn/ui

shadcn/ui includes a Drawer component built on top of Vaul:

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

function ShadcnDrawer() {
  return (
    <Drawer>
      <DrawerTrigger 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

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

Drawer.Content

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

Drawer.Handle

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

Drawer.Trigger / Drawer.Close

Use with asChild to pass props to the child element.

Pricing

LicenseCost
MIT LicenseFree
Commercial useFree
ModificationsAllowed

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

FAQ - frequently asked questions

Does Vaul work on desktop?

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

How do I prevent closing by gesture?

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

Can I use Vaul without Tailwind?

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

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

How do I add an opening animation?

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

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

Does Vaul support SSR?

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

How do I handle long content?

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

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

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

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

Can I nest more than 2 levels of drawers?

Yes, there is no depth limit. Each Drawer.NestedRoot creates a new level.