Przejdź do treści

Drawer

Panel projektowany z myślą o dotyku, który wysuwa się od krawędzi, podąża za palcem, zatrzymuje się w punktach przyciągania i zamyka gestem przesunięcia.

Kod źródłowyBase UIRejestr
"use client"

import * as React from "react"
import { MinusIcon, PlusIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

export default function DrawerDemo() {
  const [goal, setGoal] = React.useState(350)

  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>
        Ustaw cel dzienny
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Dzienny cel ruchu</DrawerTitle>
            <DrawerDescription>
              Ustaw, ile aktywnych kalorii chcesz spalać każdego dnia.
            </DrawerDescription>
          </DrawerHeader>
          <div className="flex items-center justify-center gap-6 px-6 py-6">
            <Button
              variant="outline"
              size="icon-lg"
              className="rounded-full"
              aria-label="Zmniejsz o 10"
              disabled={goal <= 200}
              onClick={() => setGoal((value) => value - 10)}
            >
              <MinusIcon />
            </Button>
            <div className="flex w-32 flex-col items-center">
              <output
                aria-live="polite"
                className="text-display-lg text-foreground tabular-nums"
              >
                {goal}
              </output>
              <span className="text-overline text-muted-foreground">
                kcal / dzień
              </span>
            </div>
            <Button
              variant="outline"
              size="icon-lg"
              className="rounded-full"
              aria-label="Zwiększ o 10"
              disabled={goal >= 800}
              onClick={() => setGoal((value) => value + 10)}
            >
              <PlusIcon />
            </Button>
          </div>
          <DrawerFooter>
            <DrawerClose render={<Button size="lg" />}>Zapisz cel</DrawerClose>
            <DrawerClose render={<Button variant="ghost" size="lg" />}>
              Anuluj
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/drawer.json

Użycie

import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer showSwipeHandle>
  <DrawerTrigger render={<Button variant="outline" />}>Set daily goal</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Daily move goal</DrawerTitle>
      <DrawerDescription>Set your daily activity goal.</DrawerDescription>
    </DrawerHeader>
    {/* … */}
    <DrawerFooter>
      <DrawerClose render={<Button size="lg" />}>Save goal</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>

Szuflada opiera się na komponencie Drawer z Base UI: panel podąża za wskaźnikiem podczas przeciągania, zamyka się, gdy gest jest wystarczająco szybki lub długi, i osiada w najbliższym punkcie zatrzymania, zamiast stanąć tam, gdzie puścił go palec.

Przykłady

Panel dolny

Wariant domyślny: szuflada, która wysuwa się od dołu i zamyka przesunięciem w dół. showSwipeHandle renderuje uchwyt, który sygnalizuje, że panel można przeciągać. Wyśrodkuj treść w kontenerze z klasą max-w-*, żeby pozostała czytelna na tabletach — wzorcowym przykładem jest panel udostępniania.

import {
  CopyIcon,
  DownloadIcon,
  LinkIcon,
  MailIcon,
  MessageCircleIcon,
  PrinterIcon,
  ShareIcon,
} from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

const targets = [
  { label: "Kopiuj link", icon: LinkIcon },
  { label: "Wiadomości", icon: MessageCircleIcon },
  { label: "Mail", icon: MailIcon },
  { label: "Duplikuj", icon: CopyIcon },
  { label: "Pobierz", icon: DownloadIcon },
  { label: "Drukuj", icon: PrinterIcon },
]

export default function DrawerShare() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>
        <ShareIcon data-icon="inline-start" />
        Udostępnij
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Udostępnij „Roadmapa Q3”</DrawerTitle>
            <DrawerDescription>
              Wyświetlać może każdy, kto ma link.
            </DrawerDescription>
          </DrawerHeader>
          <div className="grid grid-cols-3 gap-2 px-6 py-4">
            {targets.map((target) => (
              <DrawerClose
                key={target.label}
                render={
                  <button
                    type="button"
                    className="flex flex-col items-center gap-2 rounded-xl p-3 text-xs text-muted-foreground focus-ring transition-colors duration-fast hover:bg-accent hover:text-foreground"
                  />
                }
              >
                <span className="flex size-12 items-center justify-center rounded-full border bg-card text-foreground shadow-raised">
                  <target.icon className="size-5" />
                </span>
                {target.label}
              </DrawerClose>
            ))}
          </div>
          <DrawerFooter>
            <DrawerClose render={<Button variant="outline" size="lg" />}>
              Anuluj
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Punkty przyciągania

snapPoints pozwala szufladzie zatrzymywać się na kilku wysokościach. Ułamki od 0 do 1 oznaczają część wysokości obszaru widoku, liczby większe od 1 — piksele, a ciągi znaków przyjmują px i rem. Szuflada otwiera się w pierwszym punkcie; przeciągnij ją w górę, żeby dotrzeć do następnego.

import { MapPinIcon, StarIcon, XIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

const places = [
  { name: "Blue Bottle Coffee", kind: "Kawiarnia", distance: "120 m", rating: 4.7 },
  { name: "Tartine Bakery", kind: "Piekarnia", distance: "350 m", rating: 4.8 },
  { name: "Dolores Park", kind: "Park", distance: "600 m", rating: 4.6 },
  { name: "Bi-Rite Market", kind: "Sklep spożywczy", distance: "650 m", rating: 4.5 },
  { name: "Mission Dolores", kind: "Zabytek", distance: "900 m", rating: 4.4 },
  {
    name: "Foreign Cinema",
    kind: "Restauracja",
    distance: "1,1 km",
    rating: 4.6,
  },
  { name: "The Chapel", kind: "Klub muzyczny", distance: "1,2 km", rating: 4.7 },
  {
    name: "Clarion Alley",
    kind: "Sztuka uliczna",
    distance: "1,3 km",
    rating: 4.5,
  },
]

export default function DrawerSnapPoints() {
  return (
    <Drawer showSwipeHandle snapPoints={[0.45, 1]}>
      <DrawerTrigger render={<Button variant="outline" />}>
        <MapPinIcon data-icon="inline-start" />
        Miejsca w pobliżu
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>W pobliżu</DrawerTitle>
          <DrawerDescription>
            Przeciągnij w górę, aby zobaczyć wszystkie {places.length} miejsc.
          </DrawerDescription>
        </DrawerHeader>
        {/* At the top, not in a footer: at the first snap point the bottom of
            the drawer is off-screen, and a button placed there would be too. */}
        <DrawerClose
          render={
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Zamknij"
              className="absolute top-4 right-4"
            />
          }
        >
          <XIcon />
        </DrawerClose>
        <ul className="flex flex-col overflow-y-auto px-3 pb-6">
          {places.map((place) => (
            <li
              key={place.name}
              className="flex items-center gap-3 rounded-lg px-3 py-3 hover:bg-accent"
            >
              <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-surface text-muted-foreground">
                <MapPinIcon className="size-4" />
              </span>
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="truncate text-sm font-medium text-foreground">
                  {place.name}
                </span>
                <span className="text-xs text-muted-foreground">
                  {place.kind} · {place.distance}
                </span>
              </span>
              <span className="flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
                <StarIcon className="size-3 fill-current text-warning-9" />
                {place.rating.toLocaleString("pl-PL", {
                  minimumFractionDigits: 1,
                })}
              </span>
            </li>
          ))}
        </ul>
      </DrawerContent>
    </Drawer>
  )
}
<Drawer showSwipeHandle snapPoints={[0.45, 1]}>

Kierunki

swipeDirection określa jednocześnie krawędź i gest zamykania: down (domyślnie), up, left albo right. Szuflady boczne zajmują na telefonach 75% szerokości, a od sm wzwyż mają 24 rem.

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"

const directions = [
  {
    direction: "right",
    label: "Z prawej",
    hint: "Przesuń w prawo lub naciśnij Esc, aby zamknąć.",
  },
  {
    direction: "left",
    label: "Z lewej",
    hint: "Przesuń w lewo lub naciśnij Esc, aby zamknąć.",
  },
  {
    direction: "up",
    label: "Z góry",
    hint: "Przesuń w górę lub naciśnij Esc, aby zamknąć.",
  },
] as const

export default function DrawerDirections() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {directions.map(({ direction, label, hint }) => (
        <Drawer key={direction} swipeDirection={direction} showSwipeHandle>
          <DrawerTrigger render={<Button variant="outline" />}>
            {label}
          </DrawerTrigger>
          <DrawerContent>
            <div className="flex flex-1 flex-col">
              <DrawerHeader>
                <DrawerTitle>Przesuń, aby zamknąć</DrawerTitle>
                <DrawerDescription>{hint}</DrawerDescription>
              </DrawerHeader>
              <div className="mx-6 my-2 flex min-h-24 flex-1 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
                Treść
              </div>
              <DrawerFooter>
                <DrawerClose render={<Button variant="outline" />}>
                  Zamknij
                </DrawerClose>
              </DrawerFooter>
            </div>
          </DrawerContent>
        </Drawer>
      ))}
    </div>
  )
}

Zagnieżdżanie

Gdy otworzysz szufladę z wnętrza innej, szuflada nadrzędna cofa się w głąb stosu — nieco mniejsza i przygaszona — żeby było widać, dokąd się wróci. Gdy zamkniesz gestem szufladę podrzędną, nadrzędna znów wysunie się na pierwszy plan.

import { Button } from "@/components/ui/button"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export default function DrawerNested() {
  return (
    <Drawer showSwipeHandle>
      <DrawerTrigger render={<Button variant="outline" />}>
        Przejdź do kasy
      </DrawerTrigger>
      <DrawerContent>
        <div className="mx-auto w-full max-w-sm">
          <DrawerHeader>
            <DrawerTitle>Podsumowanie zamówienia</DrawerTitle>
            <DrawerDescription>2 produkty · Wysyłka w ciągu 1–2 dni</DrawerDescription>
          </DrawerHeader>
          <dl className="flex flex-col gap-2 px-6 py-4 text-sm">
            <div className="flex justify-between">
              <dt className="text-muted-foreground">Suma częściowa</dt>
              <dd className="tabular-nums">148,00 $</dd>
            </div>
            <div className="flex justify-between">
              <dt className="text-muted-foreground">Dostawa</dt>
              <dd className="tabular-nums">6,00 $</dd>
            </div>
            <div className="flex justify-between border-t pt-2 font-medium">
              <dt>Razem</dt>
              <dd className="tabular-nums">154,00 $</dd>
            </div>
          </dl>
          <DrawerFooter>
            {/* A drawer inside a drawer: the parent steps back into a stack. */}
            <Drawer showSwipeHandle>
              <DrawerTrigger render={<Button size="lg" />}>
                Dodaj kod rabatowy
              </DrawerTrigger>
              <DrawerContent>
                <div className="mx-auto w-full max-w-sm">
                  <DrawerHeader>
                    <DrawerTitle>Kod rabatowy</DrawerTitle>
                    <DrawerDescription>
                      Wielkość liter nie ma znaczenia.
                    </DrawerDescription>
                  </DrawerHeader>
                  <div className="px-6 py-2">
                    <Field>
                      <FieldLabel htmlFor="promo-code">Kod</FieldLabel>
                      <Input id="promo-code" placeholder="WIOSNA26" />
                    </Field>
                  </div>
                  <DrawerFooter>
                    <DrawerClose render={<Button size="lg" />}>
                      Zastosuj
                    </DrawerClose>
                  </DrawerFooter>
                </div>
              </DrawerContent>
            </Drawer>
            <DrawerClose render={<Button variant="ghost" size="lg" />}>
              Wróć do koszyka
            </DrawerClose>
          </DrawerFooter>
        </div>
      </DrawerContent>
    </Drawer>
  )
}

Responsywność

Na komputerze do wyśrodkowanego okna Dialog łatwiej sięgnąć kursorem; na telefonie dolna szuflada jest w zasięgu kciuka. Zachowaj wspólną treść i przełączaj kontener za pomocą useIsMobile.

"use client"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { useIsMobile } from "@/hooks/use-mobile"

function InviteForm() {
  return (
    <FieldGroup>
      <Field>
        <FieldLabel htmlFor="invite-email">E-mail</FieldLabel>
        <Input id="invite-email" type="email" placeholder="grace@company.com" />
      </Field>
    </FieldGroup>
  )
}

/** A dialog on desktop, a bottom drawer below the `md` breakpoint. */
export default function DrawerResponsive() {
  const isMobile = useIsMobile()
  const title = "Zaproś do zespołu"
  const description = "Wyślemy e-mail z linkiem, który pozwoli dołączyć do Acme."

  if (isMobile) {
    return (
      <Drawer showSwipeHandle>
        <DrawerTrigger render={<Button variant="outline" />}>
          {title}
        </DrawerTrigger>
        <DrawerContent>
          <DrawerHeader className="text-left">
            <DrawerTitle>{title}</DrawerTitle>
            <DrawerDescription>{description}</DrawerDescription>
          </DrawerHeader>
          <div className="px-6 py-2">
            <InviteForm />
          </div>
          <DrawerFooter>
            <Button size="lg">Wyślij zaproszenie</Button>
            <DrawerClose render={<Button variant="ghost" size="lg" />}>
              Anuluj
            </DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    )
  }

  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>
        {title}
      </DialogTrigger>
      <DialogContent className="max-w-md">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <InviteForm />
        <DialogFooter>
          <DialogClose render={<Button variant="outline" />}>
            Anuluj
          </DialogClose>
          <Button>Wyślij zaproszenie</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
const isMobile = useIsMobile()

return isMobile ? (
  <Drawer showSwipeHandle>{/* … */}</Drawer>
) : (
  <Dialog>{/* … */}</Dialog>
)

Wytyczne

Kiedy używać

  • Do zadań kontekstowych na urządzeniach dotykowych: wyboru wartości, krótkich formularzy, udostępniania, filtrów.
  • Gdy treść zyskuje na tym, że panel może zatrzymać się na kilku wysokościach — np. mapa z listą miejsc.

Kiedy nie używać

  • W desktopowych układach obsługiwanych głównie myszą — użyj Dialog albo Sheet.
  • Do potwierdzania akcji nieodwracalnych — użyj Alert Dialog, którego nie da się przypadkiem zamknąć gestem.
  • Do długich, złożonych formularzy — daj im osobną stronę.

Gest to skrót, nigdy jedyna droga

Wszystko, co robi gest, musi być możliwe także stuknięciem lub klawiszem. Zostaw w stopce widoczną akcję zamknięcia lub anulowania i nie polegaj na przesuwaniu między punktami przyciągania, żeby odsłonić kluczową treść.

Udostępnij album24 zdjęcia
DobrzeSzufladę zamyka widoczna akcja; gest przesunięcia to tylko skrót.
Udostępnij albumPrzesuń w dół, aby zamknąć
ŹleUkryte gesty są niewidoczne dla osób korzystających z czytnika ekranu i klawiatury.

W zasięgu kciuka

Główne akcje umieszczaj na dole szuflady, na całą szerokość, w rozmiarze lg (40 px wysokości). Tam są kciuki, a taki przycisk z zapasem spełnia wymóg minimalnego celu dotyku 24 px.

Dostępność

Szuflada jest modalnym oknem dialogowym: zatrzymuje fokus w środku, blokuje przewijanie strony, a nazwę i opis otrzymuje z DrawerTitle i DrawerDescription.

KlawiszDziałanie
EnterSpacja
Na wyzwalaczu: otwiera szufladę i przenosi do niej fokus.
Tab
Przechodzi między kontrolkami szuflady. Dopóki jest otwarta, fokus nie wychodzi poza nią.
ShiftTab
Przenosi fokus wstecz.
Esc
Zamyka szufladę i przywraca fokus do wyzwalacza.
  • Zawsze dodawaj tytuł. Nadaje szufladzie nazwę dla technologii wspomagających; jeśli projekt go nie przewiduje, ukryj go klasą sr-only.
  • Uchwyt do przeciągania jest dekoracyjny i ukryty przed technologiami wspomagającymi — zapewnij prawdziwy przycisk zamykania.
  • Ruch. Podczas przeciągania szuflada podąża za wskaźnikiem 1:1, a potem osiada w ciągu 480 ms (duration-slower). Przy ograniczonym ruchu animacja osiadania znika; przeciąganie nadal podąża za palcem, bo ten ruch wywołuje człowiek, a nie interfejs.

Dokumentacja API

Drawer

Komponent główny. Nie renderuje żadnego elementu. Przyjmuje wszystkie propsy Drawer.Root z Base UI.

PropTypDomyślnie
swipeDirection

Krawędź, do której przylega szuflada, i kierunek gestu, który ją zamyka.

"down" | "up" | "left" | "right""down"
snapPoints

Pozycje, w których szuflada może się zatrzymać. 0–1 to ułamek wysokości obszaru widoku, > 1 to piksele, ciągi znaków przyjmują px i rem.

(number | string)[]Brak wartości domyślnej
snapPoint

Aktywny punkt przyciągania. Używaj razem z onSnapPointChange, żeby nim sterować.

number | string | nullBrak wartości domyślnej
defaultSnapPoint

Początkowy punkt przyciągania, w trybie niekontrolowanym.

number | string | nullBrak wartości domyślnej
onSnapPointChange

Wywoływana, gdy szuflada osiądzie w innym punkcie przyciągania.

(snapPoint, details) => voidBrak wartości domyślnej
snapToSequentialPoints

Przesuwa szufladę o jeden punkt przyciągania naraz, zamiast przeskakiwać punkty przy szybkich gestach.

booleanfalse
showSwipeHandle

Renderuje uchwyt do przeciągania. Tylko w prfct.

booleanfalse
open

Czy szuflada jest otwarta. Używaj razem z onOpenChange, żeby nią sterować.

booleanBrak wartości domyślnej
defaultOpen

Czy szuflada jest początkowo otwarta, w trybie niekontrolowanym.

booleanfalse
onOpenChange

Wywoływana, gdy szuflada się otwiera lub zamyka. Przy gestach details.reason ma wartość "swipe".

(open: boolean, details) => voidBrak wartości domyślnej
modal

true zatrzymuje fokus w szufladzie, blokuje przewijanie i zasłania stronę przyciemnionym tłem. "trap-focus" zatrzymuje fokus, ale stronę nadal można przewijać i klikać. false pozostawia stronę interaktywną. Przyciemnione tło jest renderowane tylko dla true.

boolean | "trap-focus"true
disablePointerDismissal

Zapobiega zamknięciu po kliknięciu przyciemnionego tła.

booleanfalse

DrawerContent

Renderuje portal, przyciemnione tło, viewport i panel; przyciemnione tło jest pomijane, jeśli modal ma wartość inną niż true. Przyjmuje wszystkie propsy Drawer.Popup z Base UI, w tym initialFocus i finalFocus.

Obszar treści przewija się, gdy jest wyższy, niż szuflada może urosnąć: szuflady wysuwane w pionie mają wysokość ograniczoną do 100dvh - 6rem, co na telefonie w orientacji poziomej zostawia niewiele miejsca. Nagłówek i stopka przewijają się razem z treścią. Żeby zostały na miejscu, umieść długą część we własnym elemencie z klasą overflow-y-auto, tak jak lista w przykładzie z punktami przyciągania.

DrawerPortal, DrawerOverlay (przyciemnione tło) i DrawerSwipeHandle (uchwyt do przeciągania) to części, z których składa się DrawerContent. Są eksportowane na potrzeby własnych kompozycji; poza tym nie są potrzebne.

DrawerHeader, DrawerFooter

DrawerHeader zawiera tytuł i opis — w szufladach wysuwanych w pionie są one poniżej md wyśrodkowane, a powyżej wyrównane do lewej. DrawerFooter układa akcje jedna pod drugą, na całą szerokość, na dole panelu.

DrawerTitle, DrawerDescription

DrawerTitle renderuje <h2> i nadaje szufladzie nazwę. DrawerDescription renderuje <p> i ją opisuje.

DrawerTrigger, DrawerClose

Otwierają i zamykają szufladę. Oba renderują <button>; użyj render, żeby wyrenderować Button z prfct.