Przejdź do treści

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.

Kod źródłowyBase UIRejestr
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.json

Uż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>
  )
}

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.

DobrzeAkcje są w przypiętej stopce, zawsze w zasięgu ręki.
ŹleAkcje na końcu długiej treści znikają z widoku, gdy ją przewijasz.

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.

KlawiszDział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" na SheetTitle, 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ść SheetClose w 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 enter i 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.

PropTypDomyślnie
side

Krawędź, z której wysuwa się panel.

"top" | "right" | "bottom" | "left""right"
variant

floating odsuwa panel o 8 px od krawędzi okna i zaokrągla wszystkie narożniki.

"default" | "floating""default"
showCloseButton

Renderuje w prawym górnym rogu przycisk z ikoną, który zamyka panel.

booleantrue
initialFocus

Element, który otrzymuje fokus po otwarciu.

boolean | RefObject | (interaction) => HTMLElement | booleanBrak wartości domyślnej
finalFocus

Element, który otrzymuje fokus po zamknięciu.

boolean | RefObject | (interaction) => HTMLElement | booleanBrak wartości domyślnej
className

Łączona z klasami panelu. Szerokość paneli z lewej i prawej strony nadpiszesz klasami w-*.

stringBrak wartości domyślnej

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.