Skip to content

Table

Semantic HTML tables for structured data, with tabular numerals, row states and the parts to build sortable, selectable data tables.

SourceRegistry
DeployStatusTime
#1284Building31s
#1283Ready48s
#1282Error12s
import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const deployments = [
  { id: "#1284", status: "Building", time: "31s" },
  { id: "#1283", status: "Ready", time: "48s" },
  { id: "#1282", status: "Error", time: "12s" },
] as const

const variants = {
  Building: "warning",
  Ready: "success",
  Error: "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="Recent deployments">
        <TableHeader>
          <TableRow>
            <TableHead scope="col">Deploy</TableHead>
            <TableHead scope="col">Status</TableHead>
            <TableHead scope="col" className="text-right">
              Time
            </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>
  )
}

Anatomy

Recent deployments
DeployBranchDuration
#1284main31s
#1283fix/focus1m 48s
  1. 1ContainerScrolls the table sideways when it's wider than the page, instead of squeezing the columns.
  2. 2CaptionNames the table for screen readers. Show it below the table, or hide it visually.
  3. 3Header cellA column label in small, muted text. Sortable columns put a button here and set aria-sort.
  4. 4Numeric cellNumbers align right in tabular figures, so digits line up down the column.
  5. 5Row48px tall in the body, divided by hairlines. Body rows tint on hover, and with the brand color when selected.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/table.json

Usage

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 wraps the <table> in a horizontally scrolling container, so wide tables never break the page layout on small screens. Numbers use tabular figures by default, so digits line up column by column.

Examples

A caption names the table for everyone — it's the first thing a screen reader announces. TableFooter holds totals on a tinted row.

Usage for September 2026. Prices exclude tax.
ResourceUnitQuantityCost
ComputeGB-hours12,480$186.20
BandwidthGB3,902$58.53
StorageGB-months812$16.24
Build minutesminutes6,140$30.70
Total$291.67
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const usage = [
  {
    resource: "Compute",
    unit: "GB-hours",
    quantity: "12,480",
    cost: "$186.20",
  },
  { resource: "Bandwidth", unit: "GB", quantity: "3,902", cost: "$58.53" },
  { resource: "Storage", unit: "GB-months", quantity: "812", cost: "$16.24" },
  {
    resource: "Build minutes",
    unit: "minutes",
    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">
          Usage for September 2026. Prices exclude tax.
        </TableCaption>
        <TableHeader>
          <TableRow>
            <TableHead scope="col">Resource</TableHead>
            <TableHead scope="col">Unit</TableHead>
            <TableHead scope="col" className="text-right">
              Quantity
            </TableHead>
            <TableHead scope="col" className="text-right">
              Cost
            </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}>Total</TableCell>
            <TableCell className="text-right">$291.67</TableCell>
          </TableRow>
        </TableFooter>
      </Table>
    </div>
  )
}

Data table

Selection, sorting and row actions are composition, not configuration: a checkbox column, header buttons that cycle the sort, and a menu per row. The header checkbox shows an indeterminate state when some — but not all — rows are selected, and selected rows take the brand tint.

1 of 6 selected

StatusActions
Kestrel Healthaccounts@kestrel.health
Paid$9,750.00
Northwind Tradersbilling@northwind.dev
Paid$4,820.00
Pinecone Labsbilling@pinecone.dev
Paid$2,190.75
Lumen Studiofinance@lumen.studio
Pending$1,260.00
Oakline Banktreasury@oakline.com
Refunded$640.00
Atlas Roboticsap@atlasrobotics.io
Failed$312.50
"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 = "Paid" | "Pending" | "Failed" | "Refunded"

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: "Paid",
    amount: 4820,
  },
  {
    id: "pay_8K3",
    customer: "Lumen Studio",
    email: "finance@lumen.studio",
    status: "Pending",
    amount: 1260,
  },
  {
    id: "pay_8K4",
    customer: "Atlas Robotics",
    email: "ap@atlasrobotics.io",
    status: "Failed",
    amount: 312.5,
  },
  {
    id: "pay_8K5",
    customer: "Kestrel Health",
    email: "accounts@kestrel.health",
    status: "Paid",
    amount: 9750,
  },
  {
    id: "pay_8K6",
    customer: "Oakline Bank",
    email: "treasury@oakline.com",
    status: "Refunded",
    amount: 640,
  },
  {
    id: "pay_8K7",
    customer: "Pinecone Labs",
    email: "billing@pinecone.dev",
    status: "Paid",
    amount: 2190.75,
  },
]

const statusVariant = {
  Paid: "success",
  Pending: "warning",
  Failed: "destructive",
  Refunded: "secondary",
} as const

type SortKey = "customer" | "amount"
type Sort = { key: SortKey; direction: "ascending" | "descending" }

const currency = new Intl.NumberFormat("en-US", {
  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
            ? `${selected.size} of ${payments.length} selected`
            : `${payments.length} payments`}
        </p>
        {selected.size > 0 && (
          <div className="flex gap-2">
            <Button
              size="sm"
              variant="outline"
              onClick={() => setSelected(new Set())}
            >
              Clear
            </Button>
            <Button size="sm" variant="outline">
              Export {selected.size}
            </Button>
          </div>
        )}
      </div>
      <div className="rounded-xl border bg-card shadow-raised">
        <Table aria-label="Payments">
          <TableHeader>
            <TableRow>
              <TableHead scope="col">
                <Checkbox
                  aria-label="Select all payments"
                  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="Customer"
                  column="customer"
                  sort={sort}
                  onSort={toggleSort}
                />
              </TableHead>
              <TableHead scope="col">Status</TableHead>
              <TableHead
                scope="col"
                aria-sort={ariaSort("amount")}
                className="text-right"
              >
                <SortButton
                  label="Amount"
                  column="amount"
                  sort={sort}
                  onSort={toggleSort}
                  align="end"
                />
              </TableHead>
              <TableHead scope="col">
                <span className="sr-only">Actions</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={`Select payment from ${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={`Actions for ${payment.customer}`}
                          />
                        }
                      >
                        <MoreHorizontalIcon />
                      </DropdownMenuTrigger>
                      <DropdownMenuContent align="end" className="w-44">
                        <DropdownMenuGroup>
                          <DropdownMenuItem>View payment</DropdownMenuItem>
                          <DropdownMenuItem>Copy payment ID</DropdownMenuItem>
                        </DropdownMenuGroup>
                        <DropdownMenuSeparator />
                        <DropdownMenuGroup>
                          <DropdownMenuItem variant="destructive">
                            Refund
                          </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>
Large datasets
The example keeps state in React. For server-side sorting, pagination and virtualization, pair these parts with a headless library such as TanStack Table — the markup stays the same.

Empty

When a query returns nothing, keep the header so people understand what would be there, and put an Empty state in a single full-width cell with a way out.

DeploymentBranchAuthorDuration
No deployments match “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="Deployments">
        <TableHeader>
          <TableRow>
            <TableHead scope="col">Deployment</TableHead>
            <TableHead scope="col">Branch</TableHead>
            <TableHead scope="col">Author</TableHead>
            <TableHead scope="col" className="text-right">
              Duration
            </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>No deployments match “hotfix/*”</EmptyTitle>
                  <EmptyDescription>
                    Try another branch or clear the filter to see all 214
                    deployments.
                  </EmptyDescription>
                </EmptyHeader>
                <EmptyContent>
                  <Button size="sm" variant="outline">
                    Clear filter
                  </Button>
                </EmptyContent>
              </Empty>
            </TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </div>
  )
}

Loading

Show skeleton rows in the shape of the real content while data loads, and mark the table aria-busy so assistive technology waits for it.

CustomerPlanMRR
Loading customers…
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="Customers" aria-busy="true">
        <TableHeader>
          <TableRow>
            <TableHead scope="col">Customer</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">
        Loading customers…
      </span>
    </div>
  )
}

Guidelines

When to use

  • To compare many objects across the same set of attributes.
  • When people need to scan, sort, filter or act on rows.

When not to use

  • For layout. Tables are for data; use grid or flex for arranging things on a page.
  • For a handful of objects with rich content — use Cards or a list of Items.
  • For a single record's fields — use a description list.

Alignment

  • Text left, numbers right. Right-aligned numbers put units, tens and hundreds in the same column — together with tabular figures, magnitudes compare at a glance.
  • Align the header with its column's content, including the sort button.
  • Keep units in the header (Cost (USD), Duration (s)) rather than repeating them in every cell.
PlanMRR (USD)
Pro1,240.00
Team86.50
Enterprise12,900.00
DoNumbers right-aligned with tabular figures: magnitudes line up.
PlanMRR (USD)
Pro1,240.00
Team86.50
Enterprise12,900.00
Don’tLeft-aligned numbers hide their magnitude until you read every digit.

Density

Rows are 48px tall — comfortable for mixed content with badges and actions. For read-only, numbers-only tables, reduce cell height with className="h-10"; don't go below 32px, which is the smallest comfortable pointer target.

Row actions

Put actions for a single row in an overflow menu at the end of the row, and actions for many rows in a toolbar that appears when rows are selected. Don't make the whole row clickable and put buttons inside it.

Accessibility

prfct tables are real <table> elements, so rows, columns and headers are announced without extra ARIA.

KeyBehavior
Tab
Moves between interactive elements inside the table: checkboxes, sort buttons, row menus.
Space
Toggles the focused row checkbox.
EnterSpace
Activates the focused sort button or opens the row menu.
  • Name the table. Use TableCaption, or aria-label when a visible heading already names it.
  • Scope headers. Add scope="col" to column headers (and scope="row" to row headers) so each cell is announced with its header.
  • Announce sorting. Set aria-sort on the sorted TableHead (ascending, descending or none). The icon alone isn't announced.
  • Label every control. Row checkboxes and menus need labels that name the row — "Select payment from Northwind Traders", not "Select".
  • Selection isn't color. The selected tint is reinforced by the checked checkbox; don't rely on the tint alone.

API reference

Each part renders the matching HTML element and accepts all of its props.

ComponentElementNotes
Table<table>Wrapped in a horizontally scrolling <div data-slot="table-container">; containerClassName styles it (overflow-visible lets a sticky header stick to the page). Tabular numerals on.
TableHeader<thead>Bottom border; header rows don't take a hover state.
TableBody<tbody>Removes the border from the last row.
TableFooter<tfoot>Tinted surface and medium weight for totals.
TableRow<tr>Hover tint; data-state="selected" or aria-selected="true" applies the selected tint.
TableHead<th>40px tall, small muted text. Checkbox columns shrink to fit.
TableCell<td>48px tall. First and last cells get 16px outer padding.
TableCaption<caption>Rendered below the table in muted text.