Table
Semantyczne tabele HTML do danych ustrukturyzowanych — z cyframi tabelarycznymi, stanami wierszy i częściami do budowy tabel z sortowaniem i zaznaczaniem.
import { Badge, BadgeDot } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const deployments = [
{ id: "#1284", status: "Budowanie", time: "31 s" },
{ id: "#1283", status: "Gotowe", time: "48 s" },
{ id: "#1282", status: "Błąd", time: "12 s" },
] as const
const variants = {
"Budowanie": "warning",
"Gotowe": "success",
"Błąd": "destructive",
} as const
export default function TableDemo() {
return (
<div className="w-full max-w-sm rounded-xl border bg-card shadow-raised">
<Table aria-label="Ostatnie wdrożenia">
<TableHeader>
<TableRow>
<TableHead scope="col">Wdrożenie</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead scope="col" className="text-right">
Czas
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map((deployment) => (
<TableRow key={deployment.id}>
<TableCell className="font-medium">{deployment.id}</TableCell>
<TableCell>
<Badge variant={variants[deployment.status]}>
<BadgeDot />
{deployment.status}
</Badge>
</TableCell>
<TableCell className="text-right">{deployment.time}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Anatomia
- 1KontenerPrzewija tabelę w poziomie, gdy jest szersza od strony, zamiast ściskać kolumny.
- 2PodpisNazywa tabelę dla czytników ekranu. Pokaż go pod tabelą albo ukryj wizualnie.
- 3Komórka nagłówkaEtykieta kolumny, zapisana małym, stonowanym tekstem. Kolumny z sortowaniem mają tu przycisk i ustawiają aria-sort.
- 4Komórka liczbowaLiczby są wyrównane do prawej i zapisane cyframi tabelarycznymi, więc w całej kolumnie cyfry stoją równo jedna pod drugą.
- 5WierszW treści tabeli ma 48 px wysokości, a od sąsiednich wierszy oddziela go cienka linia. Wiersze treści zabarwiają się po najechaniu, a po zaznaczeniu przyjmują kolor marki.
Instalacja
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/table.jsonUżycie
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"<Table>
<TableCaption>Usage for September 2026.</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">Resource</TableHead>
<TableHead scope="col" className="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Compute</TableCell>
<TableCell className="text-right">$186.20</TableCell>
</TableRow>
</TableBody>
</Table>Table otacza element <table> kontenerem przewijanym w poziomie, dzięki czemu szeroka tabela nigdy nie rozbija układu strony na małych ekranach. Liczby domyślnie korzystają z cyfr tabelarycznych, więc cyfry układają się w równe kolumny.
Przykłady
Podpis i stopka
Podpis nazywa tabelę dla wszystkich — to pierwsza rzecz, którą odczytuje czytnik ekranu. TableFooter mieści sumy w zabarwionym wierszu.
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const usage = [
{
resource: "Moc obliczeniowa",
unit: "GB-godziny",
quantity: "12 480",
cost: "186,20 $",
},
{ resource: "Transfer", unit: "GB", quantity: "3 902", cost: "58,53 $" },
{ resource: "Miejsce na dane", unit: "GB-miesiące", quantity: "812", cost: "16,24 $" },
{
resource: "Minuty kompilacji",
unit: "minuty",
quantity: "6 140",
cost: "30,70 $",
},
]
export default function TableBasic() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card shadow-raised">
<Table>
<TableCaption className="mb-4">
Zużycie we wrześniu 2026 r. Ceny nie zawierają podatku.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">Zasób</TableHead>
<TableHead scope="col">Jednostka</TableHead>
<TableHead scope="col" className="text-right">
Ilość
</TableHead>
<TableHead scope="col" className="text-right">
Koszt
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usage.map((row) => (
<TableRow key={row.resource}>
<TableCell className="font-medium">{row.resource}</TableCell>
<TableCell className="text-muted-foreground">
{row.unit}
</TableCell>
<TableCell className="text-right">{row.quantity}</TableCell>
<TableCell className="text-right">{row.cost}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Razem</TableCell>
<TableCell className="text-right">291,67 $</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)
}Tabela danych
Zaznaczanie, sortowanie i akcje wierszy to kwestia kompozycji, a nie konfiguracji: kolumna pól wyboru, przyciski w nagłówkach, które przełączają kolejne kierunki sortowania, i menu w każdym wierszu. Pole wyboru w nagłówku przechodzi w stan pośredni, gdy zaznaczono część wierszy — ale nie wszystkie — a zaznaczone wiersze przyjmują odcień marki.
Zaznaczono: 1 z 6
"use client"
import * as React from "react"
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type Status = "Zrealizowana" | "Oczekująca" | "Nieudana" | "Zwrócona"
type Payment = {
id: string
customer: string
email: string
status: Status
amount: number
}
const payments: Payment[] = [
{
id: "pay_8K2",
customer: "Northwind Traders",
email: "billing@northwind.dev",
status: "Zrealizowana",
amount: 4820,
},
{
id: "pay_8K3",
customer: "Lumen Studio",
email: "finance@lumen.studio",
status: "Oczekująca",
amount: 1260,
},
{
id: "pay_8K4",
customer: "Atlas Robotics",
email: "ap@atlasrobotics.io",
status: "Nieudana",
amount: 312.5,
},
{
id: "pay_8K5",
customer: "Kestrel Health",
email: "accounts@kestrel.health",
status: "Zrealizowana",
amount: 9750,
},
{
id: "pay_8K6",
customer: "Oakline Bank",
email: "treasury@oakline.com",
status: "Zwrócona",
amount: 640,
},
{
id: "pay_8K7",
customer: "Pinecone Labs",
email: "billing@pinecone.dev",
status: "Zrealizowana",
amount: 2190.75,
},
]
const statusVariant = {
"Zrealizowana": "success",
"Oczekująca": "warning",
"Nieudana": "destructive",
"Zwrócona": "secondary",
} as const
type SortKey = "customer" | "amount"
type Sort = { key: SortKey; direction: "ascending" | "descending" }
const currency = new Intl.NumberFormat("pl-PL", {
style: "currency",
currency: "USD",
})
function SortButton({
label,
column,
sort,
onSort,
align = "start",
}: {
label: string
column: SortKey
sort: Sort | null
onSort: (key: SortKey) => void
align?: "start" | "end"
}) {
const active = sort?.key === column
const Icon = !active
? ArrowUpDownIcon
: sort.direction === "ascending"
? ArrowUpIcon
: ArrowDownIcon
return (
<Button
variant="ghost"
size="xs"
onClick={() => onSort(column)}
className={align === "end" ? "-mr-2.5 ml-auto" : "-ml-2.5"}
>
{label}
<Icon
data-icon="inline-end"
className={active ? "text-foreground" : "text-muted-foreground"}
/>
</Button>
)
}
export default function TableDataTable() {
const [selected, setSelected] = React.useState<Set<string>>(
new Set(["pay_8K3"])
)
const [sort, setSort] = React.useState<Sort | null>({
key: "amount",
direction: "descending",
})
const rows = React.useMemo(() => {
if (!sort) return payments
const sorted = [...payments].sort((a, b) =>
sort.key === "amount"
? a.amount - b.amount
: a.customer.localeCompare(b.customer)
)
return sort.direction === "ascending" ? sorted : sorted.reverse()
}, [sort])
const allSelected = selected.size === payments.length
const someSelected = selected.size > 0 && !allSelected
function toggleSort(key: SortKey) {
setSort((current) =>
current?.key !== key
? { key, direction: "ascending" }
: current.direction === "ascending"
? { key, direction: "descending" }
: null
)
}
function toggleRow(id: string, checked: boolean) {
setSelected((current) => {
const next = new Set(current)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const ariaSort = (key: SortKey) =>
sort?.key === key ? sort.direction : ("none" as const)
return (
<div className="flex w-full max-w-3xl flex-col gap-3">
<div className="flex h-8 items-center justify-between">
<p className="text-sm text-muted-foreground" aria-live="polite">
{selected.size > 0
? `Zaznaczono: ${selected.size} z ${payments.length}`
: `Płatności: ${payments.length}`}
</p>
{selected.size > 0 && (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelected(new Set())}
>
Wyczyść
</Button>
<Button size="sm" variant="outline">
Eksportuj {selected.size}
</Button>
</div>
)}
</div>
<div className="rounded-xl border bg-card shadow-raised">
<Table aria-label="Płatności">
<TableHeader>
<TableRow>
<TableHead scope="col">
<Checkbox
aria-label="Zaznacz wszystkie płatności"
checked={allSelected}
indeterminate={someSelected}
onCheckedChange={(checked) =>
setSelected(
checked ? new Set(payments.map((p) => p.id)) : new Set()
)
}
/>
</TableHead>
<TableHead scope="col" aria-sort={ariaSort("customer")}>
<SortButton
label="Klient"
column="customer"
sort={sort}
onSort={toggleSort}
/>
</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead
scope="col"
aria-sort={ariaSort("amount")}
className="text-right"
>
<SortButton
label="Kwota"
column="amount"
sort={sort}
onSort={toggleSort}
align="end"
/>
</TableHead>
<TableHead scope="col">
<span className="sr-only">Akcje</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((payment) => {
const isSelected = selected.has(payment.id)
return (
<TableRow
key={payment.id}
data-state={isSelected ? "selected" : undefined}
>
<TableCell>
<Checkbox
aria-label={`Zaznacz płatność od ${payment.customer}`}
checked={isSelected}
onCheckedChange={(checked) =>
toggleRow(payment.id, checked)
}
/>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{payment.customer}</span>
<span className="text-xs text-muted-foreground">
{payment.email}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant[payment.status]}>
<BadgeDot />
{payment.status}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{currency.format(payment.amount)}
</TableCell>
<TableCell className="w-12">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={`Akcje dla ${payment.customer}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem>Wyświetl płatność</DropdownMenuItem>
<DropdownMenuItem>Kopiuj ID płatności</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
Zwróć środki
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)
}<TableHead aria-sort={sort?.key === "amount" ? sort.direction : "none"}>
<Button variant="ghost" size="xs" onClick={() => toggleSort("amount")}>
Amount
<ArrowUpDownIcon data-icon="inline-end" />
</Button>
</TableHead>Stan pusty
Gdy zapytanie nic nie zwraca, zostaw nagłówek — dzięki niemu wiadomo, co mogłoby się tu znaleźć — a w jednej komórce na całą szerokość tabeli umieść stan Empty, który wskazuje wyjście z sytuacji.
import { SearchXIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export default function TableEmpty() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card shadow-raised">
<Table aria-label="Wdrożenia">
<TableHeader>
<TableRow>
<TableHead scope="col">Wdrożenie</TableHead>
<TableHead scope="col">Gałąź</TableHead>
<TableHead scope="col">Autor</TableHead>
<TableHead scope="col" className="text-right">
Czas trwania
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="h-auto p-0">
<Empty className="py-10 md:py-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon />
</EmptyMedia>
<EmptyTitle>Brak wdrożeń pasujących do „hotfix/*”</EmptyTitle>
<EmptyDescription>
Wybierz inną gałąź lub wyczyść filtr, aby zobaczyć wszystkie wdrożenia (214).
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm" variant="outline">
Wyczyść filtr
</Button>
</EmptyContent>
</Empty>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)
}Ładowanie
Podczas ładowania danych pokaż wiersze szkieletowe w kształcie prawdziwej treści i oznacz tabelę atrybutem aria-busy, żeby technologie wspomagające na nią zaczekały.
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const widths = ["w-40", "w-32", "w-44", "w-36", "w-28"]
export default function TableLoading() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card shadow-raised">
<Table aria-label="Klienci" aria-busy="true">
<TableHeader>
<TableRow>
<TableHead scope="col">Klient</TableHead>
<TableHead scope="col">Plan</TableHead>
<TableHead scope="col" className="text-right">
MRR
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{widths.map((width, index) => (
<TableRow key={index} className="hover:bg-transparent">
<TableCell>
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full" />
<Skeleton className={`h-3.5 ${width}`} />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-md" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-3.5 w-14" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<span className="sr-only" role="status">
Ładowanie klientów…
</span>
</div>
)
}Wytyczne
Kiedy używać
- Do porównywania wielu obiektów według tego samego zestawu atrybutów.
- Gdy trzeba przeglądać, sortować lub filtrować wiersze albo wykonywać na nich akcje.
Kiedy nie używać
- Do budowania układu. Tabele służą do danych; do rozmieszczania elementów na stronie używaj grid lub flex.
- Do kilku obiektów z rozbudowaną treścią — sięgnij po Card albo listę elementów Item.
- Do pól pojedynczego rekordu — użyj listy opisów.
Wyrównanie
- Tekst do lewej, liczby do prawej. Liczby wyrównane do prawej ustawiają jedności, dziesiątki i setki w tych samych kolumnach — w połączeniu z cyframi tabelarycznymi rzędy wielkości da się porównać jednym spojrzeniem.
- Wyrównuj nagłówek tak samo jak zawartość jego kolumny — łącznie z przyciskiem sortowania.
- Jednostki podawaj w nagłówku (Koszt (USD), Czas (s)), zamiast powtarzać je w każdej komórce.
Gęstość
Wiersze mają 48 px wysokości — to wygodna wartość dla mieszanej treści z odznakami i akcjami. W tabelach tylko do odczytu, zawierających same liczby, zmniejsz wysokość komórek przez className="h-10"; nie schodź poniżej 32 px — to najmniejszy cel, w który wygodnie trafić wskaźnikiem.
Akcje wierszy
Akcje dotyczące jednego wiersza umieść w menu z dodatkowymi opcjami na końcu wiersza, a akcje dla wielu wierszy — na pasku narzędzi, który pojawia się po zaznaczeniu wierszy. Wiersz może być klikalny w całości albo zawierać przyciski — nigdy jedno i drugie naraz.
Dostępność
Tabele prfct to prawdziwe elementy <table>, więc wiersze, kolumny i nagłówki są odczytywane bez dodatkowych atrybutów ARIA.
| Klawisz | Działanie |
|---|---|
Tab | Przenosi fokus między elementami interaktywnymi w tabeli: polami wyboru, przyciskami sortowania, menu wierszy. |
Spacja | Zaznacza lub odznacza pole wyboru wiersza, na którym jest fokus. |
EnterSpacja | Aktywuje przycisk sortowania, na którym jest fokus, albo otwiera menu wiersza. |
- Nazwij tabelę. Użyj
TableCaptionalboaria-label, jeśli tabelę nazywa już widoczny nagłówek nad nią. - Określ zakres nagłówków. Dodaj
scope="col"do nagłówków kolumn (ascope="row"do nagłówków wierszy), żeby każda komórka była odczytywana razem ze swoim nagłówkiem. - Informuj o sortowaniu. Ustaw
aria-sortna posortowanymTableHead(ascending,descendinglubnone). Sama ikona nie zostanie odczytana. - Nadaj etykietę każdej kontrolce. Pola wyboru i menu w wierszach potrzebują etykiet, które nazywają wiersz — „Zaznacz płatność od Northwind Traders”, a nie „Zaznacz”.
- Zaznaczenie to nie tylko kolor. Odcień zaznaczenia zawsze idzie w parze z zaznaczonym polem wyboru — nie polegaj na samym odcieniu.
Dokumentacja API
Każda część renderuje odpowiadający jej element HTML i przyjmuje wszystkie jego propsy.