Sheet
Panel wysuwany z krawędzi ekranu na dłuższe formularze, szczegóły i filtry — strona pod spodem pozostaje przy tym w zasięgu wzroku.
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
export default function SheetDemo() {
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
Edytuj zgłoszenie
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edytuj zgłoszenie</SheetTitle>
<SheetDescription>DAT-248 · Otworzyła Ada Lovelace</SheetDescription>
</SheetHeader>
<SheetBody>
<FieldGroup>
<Field>
<FieldLabel htmlFor="issue-title">Tytuł</FieldLabel>
<Input
id="issue-title"
defaultValue="Menu rozwijane zamyka się przy przewijaniu jego zawartości"
/>
</Field>
<Field>
<FieldLabel htmlFor="issue-description">Opis</FieldLabel>
<Textarea
id="issue-description"
rows={5}
defaultValue="W Safari 18 przewinięcie długiej listy w menu je zamyka. Błąd można odtworzyć na stronie ustawień."
/>
<FieldDescription>Możesz używać Markdownu.</FieldDescription>
</Field>
</FieldGroup>
</SheetBody>
<SheetFooter>
<SheetClose render={<Button variant="outline" />}>Anuluj</SheetClose>
<Button>Zapisz zgłoszenie</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}Instalacja
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/sheet.jsonUżycie
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"<Sheet>
<SheetTrigger render={<Button variant="outline" />}>Edit issue</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit issue</SheetTitle>
<SheetDescription>DAT-248 · Opened by Ada Lovelace</SheetDescription>
</SheetHeader>
<SheetBody>{/* scrollable content */}</SheetBody>
<SheetFooter>
<SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
<Button>Save issue</Button>
</SheetFooter>
</SheetContent>
</Sheet>Panel to Dialog osadzony przy krawędzi ekranu. Działa tak samo — pułapka fokusu, blokada przewijania, zamykanie klawiszem Esc — i dodaje trzyczęściowy układ: nieruchomy SheetHeader, przewijany SheetBody i SheetFooter przypięty do dołu.
Przykłady
Krawędzie
side wybiera krawędź: right (domyślnie) do szczegółów i edycji, left do nawigacji, top i bottom do krótkiej treści na całą szerokość. Panele wysuwane z boku mają 26 rem szerokości i nigdy nie podchodzą bliżej niż na 3 rem do przeciwległej krawędzi; panele górne i dolne zajmują najwyżej 85% wysokości okna.
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const sides = [
{
side: "top",
label: "U góry",
title: "Panel górny",
description:
"Wysuwa się od górnej krawędzi, a po zamknięciu przywraca fokus do wyzwalacza.",
},
{
side: "right",
label: "Po prawej",
title: "Panel prawy",
description:
"Wysuwa się od prawej krawędzi, a po zamknięciu przywraca fokus do wyzwalacza.",
},
{
side: "bottom",
label: "Na dole",
title: "Panel dolny",
description:
"Wysuwa się od dolnej krawędzi, a po zamknięciu przywraca fokus do wyzwalacza.",
},
{
side: "left",
label: "Po lewej",
title: "Panel lewy",
description:
"Wysuwa się od lewej krawędzi, a po zamknięciu przywraca fokus do wyzwalacza.",
},
] as const
export default function SheetSides() {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{sides.map(({ side, label, title, description }) => (
<Sheet key={side}>
<SheetTrigger render={<Button variant="outline" />}>
{label}
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<SheetBody>
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
Treść
</div>
</SheetBody>
</SheetContent>
</Sheet>
))}
</div>
)
}Pływający
variant="floating" odsuwa panel o 8 px od krawędzi okna i zaokrągla wszystkie jego narożniki, więc wygląda jak powierzchnia unosząca się nad stroną, a nie jak część ramy okna. Pasuje do lekkich paneli, takich jak powiadomienia.
import {
BellIcon,
GitPullRequestIcon,
MessageSquareIcon,
RocketIcon,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import {
Sheet,
SheetBody,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const notifications = [
{
icon: RocketIcon,
title: "Wdrożenie produkcyjne zakończone",
body: "atlas-web · 7f3a2c1 wdrożono w 38 s.",
time: "2 min",
},
{
icon: GitPullRequestIcon,
title: "Grace poprosiła cię o recenzję",
body: "#1284 Dodaj obsługę klawiatury w wyborze daty",
time: "18 min",
},
{
icon: MessageSquareIcon,
title: "Linus odpowiedział na twój komentarz",
body: "„Zgoda — ustawmy sprężystą krzywą przejścia jako domyślną.”",
time: "1 godz.",
},
]
export default function SheetFloating() {
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
<BellIcon data-icon="inline-start" />
Powiadomienia
<Badge variant="brand" size="sm" className="-mr-1">
3
</Badge>
</SheetTrigger>
<SheetContent
variant="floating"
className="w-[min(24rem,calc(100%-1rem))]"
>
<SheetHeader>
<SheetTitle>Powiadomienia</SheetTitle>
<SheetDescription>3 nieprzeczytane od wczoraj.</SheetDescription>
</SheetHeader>
<SheetBody className="px-3">
<ItemGroup className="gap-1">
{notifications.map((notification) => (
<Item key={notification.title} size="sm" render={<a href="#" />}>
<ItemMedia variant="icon">
<notification.icon />
</ItemMedia>
<ItemContent>
<ItemTitle>{notification.title}</ItemTitle>
<ItemDescription>{notification.body}</ItemDescription>
</ItemContent>
<span className="self-start text-xs text-muted-foreground">
{notification.time}
</span>
</Item>
))}
</ItemGroup>
</SheetBody>
</SheetContent>
</Sheet>
)
}Filtry
Filtry to modelowe zastosowanie panelu: wiele kontrolek działających na listę, która pozostaje widoczna za przyciemnionym tłem. Umieść liczbę wyników w głównej akcji, żeby ludzie wiedzieli, co dostaną, zanim zatwierdzą wybór.
"use client"
import * as React from "react"
import { SlidersHorizontalIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Sheet,
SheetBody,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
const statuses = ["Backlog", "W toku", "W przeglądzie", "Gotowe"]
const maxEstimate = 21
export default function SheetFilters() {
const [selected, setSelected] = React.useState(["W toku", "W przeglądzie"])
const [sort, setSort] = React.useState("updated")
const [estimate, setEstimate] = React.useState(8)
const [mine, setMine] = React.useState(true)
// Sorting is not a filter; the estimate is one once it is below the maximum.
const active =
selected.length + (estimate < maxEstimate ? 1 : 0) + (mine ? 1 : 0)
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>
<SlidersHorizontalIcon data-icon="inline-start" />
Filtry
{active > 0 && (
<Badge variant="secondary" size="sm" className="-mr-1">
{active}
</Badge>
)}
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Filtruj zgłoszenia</SheetTitle>
<SheetDescription>Wyświetlono 48 z 312 zgłoszeń.</SheetDescription>
</SheetHeader>
<SheetBody>
<FieldGroup className="gap-7">
<FieldSet>
<FieldLegend variant="label">Status</FieldLegend>
<FieldGroup className="gap-3">
{statuses.map((status) => {
// IDs can't contain spaces, so "In progress" becomes "status-in-progress".
const id = `status-${status.toLowerCase().replace(/\s+/g, "-")}`
return (
<Field key={status} orientation="horizontal">
<Checkbox
id={id}
checked={selected.includes(status)}
onCheckedChange={(checked) =>
setSelected((current) =>
checked
? [...current, status]
: current.filter((item) => item !== status)
)
}
/>
<FieldLabel htmlFor={id} className="font-normal">
{status}
</FieldLabel>
</Field>
)
})}
</FieldGroup>
</FieldSet>
<FieldSeparator />
<FieldSet>
<FieldLegend variant="label">Sortuj według</FieldLegend>
<RadioGroup value={sort} onValueChange={setSort}>
{[
["updated", "Ostatnia aktualizacja"],
["created", "Data utworzenia"],
["priority", "Priorytet"],
].map(([value, label]) => (
<Field key={value} orientation="horizontal">
<RadioGroupItem value={value} id={`sort-${value}`} />
<FieldLabel
htmlFor={`sort-${value}`}
className="font-normal"
>
{label}
</FieldLabel>
</Field>
))}
</RadioGroup>
</FieldSet>
<FieldSeparator />
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Maks. estymacja</FieldLabel>
<span className="text-sm text-muted-foreground tabular-nums">
{estimate} pkt
</span>
</div>
<Slider
value={estimate}
onValueChange={(value) => setEstimate(value as number)}
min={1}
max={maxEstimate}
aria-label="Maks. estymacja"
/>
</Field>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="assigned-to-me">Przypisane do mnie</FieldLabel>
<FieldDescription>Pokaż tylko zgłoszenia przypisane do ciebie.</FieldDescription>
</FieldContent>
<Switch
id="assigned-to-me"
checked={mine}
onCheckedChange={setMine}
/>
</Field>
</FieldGroup>
</SheetBody>
<SheetFooter>
<Button
variant="ghost"
className="sm:mr-auto"
onClick={() => {
setSelected([])
setSort("updated")
setEstimate(maxEstimate)
setMine(false)
}}
>
Resetuj
</Button>
<SheetClose render={<Button />}>Pokaż 48 zgłoszeń</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}Nawigacja
Na małych ekranach nawigacja aplikacji przenosi się do lewego panelu otwieranego przyciskiem menu. Bieżącą stronę oznacz atrybutem aria-current="page".
import {
BarChart3Icon,
FolderIcon,
HomeIcon,
InboxIcon,
MenuIcon,
SettingsIcon,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
const links = [
{ label: "Strona główna", icon: HomeIcon, current: true },
{ label: "Skrzynka odbiorcza", icon: InboxIcon, count: 12 },
{ label: "Projekty", icon: FolderIcon },
{ label: "Analityka", icon: BarChart3Icon },
{ label: "Ustawienia", icon: SettingsIcon },
]
export default function SheetNavigation() {
return (
<Sheet>
<SheetTrigger
render={
<Button variant="outline" size="icon" aria-label="Otwórz nawigację" />
}
>
<MenuIcon />
</SheetTrigger>
<SheetContent side="left" className="w-[min(18rem,calc(100%-3rem))]">
<SheetHeader className="pb-4">
<SheetTitle>Acme Inc.</SheetTitle>
</SheetHeader>
<SheetBody className="px-3">
<nav aria-label="Główna">
<ul className="flex flex-col gap-px">
{links.map((link) => (
<li key={link.label}>
<a
href="#"
aria-current={link.current ? "page" : undefined}
className="flex h-9 items-center gap-2.5 rounded-lg px-2.5 text-sm text-muted-foreground focus-ring-inset transition-colors duration-fast hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
>
<link.icon className="size-4" />
{link.label}
{link.count !== undefined && (
<Badge variant="secondary" size="sm" className="ml-auto">
{link.count}
</Badge>
)}
</a>
</li>
))}
</ul>
</nav>
</SheetBody>
</SheetContent>
</Sheet>
)
}Szerokość
Szerokość to zwykła klasa narzędziowa, więc nadpisz ją przez className — na przykład className="w-[min(40rem,calc(100%-3rem))]" dla szerokiego widoku szczegółów.
Wytyczne
Kiedy używać
- Do edycji lub podglądu elementu listy bez utraty miejsca na tej liście.
- Do formularzy zbyt długich na okno dialogowe, które jednak nie zasługują na osobną stronę.
- Do nawigacji pomocniczej i filtrów na małych ekranach.
Kiedy nie używać
- Do szybkiej decyzji lub krótkiego formularza — użyj komponentu Dialog.
- Do mobilnych paneli dolnych z gestami przesuwania i punktami przyciągania — użyj komponentu Drawer.
- Do stałej nawigacji bocznej na dużych ekranach — użyj komponentu Sidebar.
Struktura
Nagłówek i stopka powinny być krótkie, żeby miejsce zostało dla treści. Tytuł nazywa obiekt, opis dodaje jedną linijkę kontekstu (identyfikator, właściciela, liczbę), a stopka mieści najwyżej dwie akcje i opcjonalne Resetuj po przeciwnej stronie.
Nie nakładaj paneli na siebie
Okno dialogowe otwarte nad panelem jest w porządku, gdy służy do potwierdzenia. Panel otwarty nad panelem — już nie: podmień treść albo przenieś ją na osobną stronę.
Dostępność
Panel jest modalnym oknem dialogowym z role="dialog"; nazwę nadaje mu SheetTitle, a opis — SheetDescription.
| Klawisz | Działanie |
|---|---|
EnterSpacja | Na wyzwalaczu: otwiera panel i przenosi do niego fokus. |
Tab | Przechodzi między kontrolkami panelu. Fokus nie opuszcza panelu, dopóki ten się nie zamknie. |
ShiftTab | Przenosi fokus wstecz, a z początku przeskakuje na koniec. |
Esc | Zamyka panel i przywraca fokus na wyzwalacz. |
- Zawsze dodawaj tytuł. Użyj
className="sr-only"naSheetTitle, gdy projekt pokazuje w tym miejscu coś innego, na przykład logo. - Przycisk zamykania (
showCloseButton, domyślnie włączony) to sposób, w jaki użytkownicy czytników ekranu na urządzeniach dotykowych opuszczają panel. Zostaw go albo umieśćSheetClosew stopce. - Kolejność czytania. Nagłówek, treść i stopka występują w kolejności DOM, więc czytniki ekranu i użytkownicy klawiatury docierają do akcji w stopce po treści — w tej samej kolejności, w jakiej widzą je wszyscy.
- Ruch. Panele wsuwają się w ciągu 320 ms z krzywą przejścia
enteri znikają w 160 ms. Przy ograniczonym ruchu pojawiają się od razu na swoim miejscu.
Dokumentacja API
Sheet
Komponent główny. Nie renderuje żadnego elementu. Przyjmuje wszystkie propsy Dialog.Root z Base UI: open, defaultOpen, onOpenChange, modal, disablePointerDismissal, actionsRef.
SheetContent
Renderuje portal, przyciemnione tło i sam panel.
SheetHeader, SheetBody, SheetFooter
SheetHeader zawiera tytuł i opis oraz zostawia miejsce na przycisk zamykania. SheetBody rośnie, by wypełnić panel, i przewija się niezależnie. SheetFooter jest przypięty do dołu i oddzielony cienką linią; na małych ekranach układa przyciski jeden pod drugim, a od sm wzwyż wyrównuje je do prawej.
SheetTitle, SheetDescription
SheetTitle renderuje <h2> i nadaje panelowi nazwę. SheetDescription renderuje <p> i go opisuje.
SheetTrigger, SheetClose
Otwierają i zamykają panel. Oba renderują <button>; użyj render, aby wyrenderować Button z prfct.