Przejdź do treści

Table

Semantyczne tabele HTML do danych ustrukturyzowanych — z cyframi tabelarycznymi, stanami wierszy i częściami do budowy tabel z sortowaniem i zaznaczaniem.

Kod źródłowyRejestr
WdrożenieStatusCzas
#1284Budowanie31 s
#1283Gotowe48 s
#1282Błąd12 s
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

Ostatnie wdrożenia
WdrożenieGałąźCzas trwania
#1284main31 s
#1283fix/focus1 min 48 s
  1. 1KontenerPrzewija tabelę w poziomie, gdy jest szersza od strony, zamiast ściskać kolumny.
  2. 2PodpisNazywa tabelę dla czytników ekranu. Pokaż go pod tabelą albo ukryj wizualnie.
  3. 3Komórka nagłówkaEtykieta kolumny, zapisana małym, stonowanym tekstem. Kolumny z sortowaniem mają tu przycisk i ustawiają aria-sort.
  4. 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ą.
  5. 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.json

Uż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 nazywa tabelę dla wszystkich — to pierwsza rzecz, którą odczytuje czytnik ekranu. TableFooter mieści sumy w zabarwionym wierszu.

Zużycie we wrześniu 2026 r. Ceny nie zawierają podatku.
ZasóbJednostkaIlośćKoszt
Moc obliczeniowaGB-godziny12 480186,20 $
TransferGB3 90258,53 $
Miejsce na daneGB-miesiące81216,24 $
Minuty kompilacjiminuty6 14030,70 $
Razem291,67 $
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

StatusAkcje
Kestrel Healthaccounts@kestrel.health
Zrealizowana9750,00 USD
Northwind Tradersbilling@northwind.dev
Zrealizowana4820,00 USD
Pinecone Labsbilling@pinecone.dev
Zrealizowana2190,75 USD
Lumen Studiofinance@lumen.studio
Oczekująca1260,00 USD
Oakline Banktreasury@oakline.com
Zwrócona640,00 USD
Atlas Roboticsap@atlasrobotics.io
Nieudana312,50 USD
"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>
Duże zbiory danych
Przykład przechowuje stan w Reakcie. Do sortowania po stronie serwera, paginacji i wirtualizacji połącz te części z biblioteką typu headless, taką jak TanStack Table — znaczniki pozostają bez zmian.

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.

WdrożenieGałąźAutorCzas trwania
Brak wdrożeń pasujących do „hotfix/*”
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.

KlientPlanMRR
Ładowanie klientów…
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.
PlanMRR (USD)
Pro1 240,00
Team86,50
Enterprise12 900,00
DobrzeLiczby wyrównane do prawej, zapisane cyframi tabelarycznymi: rzędy wielkości stoją w jednej linii.
PlanMRR (USD)
Pro1 240,00
Team86,50
Enterprise12 900,00
ŹleLiczby wyrównane do lewej ukrywają rząd wielkości, dopóki nie przeczytasz każdej cyfry.

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.

KlawiszDział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 TableCaption albo aria-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 (a scope="row" do nagłówków wierszy), żeby każda komórka była odczytywana razem ze swoim nagłówkiem.
  • Informuj o sortowaniu. Ustaw aria-sort na posortowanym TableHead (ascending, descending lub none). 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.

KomponentElementUwagi
Table<table>Otoczony elementem <div data-slot="table-container">, który przewija się w poziomie; containerClassName nadaje mu style (overflow-visible pozwala przykleić nagłówek do strony). Cyfry tabelaryczne włączone.
TableHeader<thead>Dolne obramowanie; wiersze nagłówka nie mają stanu najechania.
TableBody<tbody>Usuwa obramowanie z ostatniego wiersza.
TableFooter<tfoot>Zabarwiona powierzchnia i średnia grubość pisma, z myślą o sumach.
TableRow<tr>Odcień po najechaniu; data-state="selected" lub aria-selected="true" nadaje odcień zaznaczenia.
TableHead<th>Wysokość 40 px, mały stonowany tekst. Kolumny z polami wyboru zwężają się do zawartości.
TableCell<td>Wysokość 48 px. Pierwsza i ostatnia komórka mają 16 px odstępu od krawędzi tabeli.
TableCaption<caption>Wyświetlany pod tabelą stonowanym tekstem.