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.
"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.jsonUż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ść.
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.
| Klawisz | Dział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.
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.