Data display
Table
Table styles the native HTML table for structured, read-only data: quiet headers, hairline rows, hover or zebra rows, right-aligned tabular figures for numbers, totals in a footer and an optional sticky header. It scrolls sideways inside its own frame, so the page never does. For sorting, search, selection and pagination, use Data table.
| Invoice | Customer | Issued | Status | Amount |
|---|---|---|---|---|
| INV-2041 | Fernhill Studio | Mar 02, 2026 | Paid | $4,200.00 |
| INV-2042 | Quillmark | Mar 04, 2026 | Pending | $1,180.50 |
| INV-2043 | Tidewell Logistics | Mar 09, 2026 | Overdue | $12,940.00 |
| INV-2044 | Copperleaf Dental | Mar 11, 2026 | Paid | $860.00 |
| INV-2045 | Halden Works | Mar 15, 2026 | Draft | $2,315.75 |
| Total | $21,496.25 | |||
<Table framed label="Invoices"> <TableCaption srOnly>March invoices</TableCaption> <TableHead> <tr> <Th>Invoice</Th> <Th>Customer</Th> <Th>Issued</Th> <Th>Status</Th> <Th numeric>Amount</Th> </tr> </TableHead> <TableBody> {invoices.map((inv) => ( <Tr key={inv.id}> <Td muted className="font-mono text-[12.5px]">{inv.id}</Td> <Td className="font-medium">{inv.customer}</Td> <Td muted>{inv.issued}</Td> <Td> <Badge tone={statusTone[inv.status]} dot>{inv.status}</Badge> </Td> <Td numeric>{inv.amount}</Td> </Tr> ))} </TableBody> <TableFoot> <tr> <Td colSpan={4}>Total</Td> <Td numeric>$21,496.25</Td> </tr> </TableFoot></Table>Usage
import { Table, TableBody, TableHead, Td, Th, Tr } from "@/components/ui/table"; <Table framed label="Invoices"> <TableHead> <tr><Th>Customer</Th><Th numeric>Amount</Th></tr> </TableHead> <TableBody> <Tr><Td>Fernhill Studio</Td><Td numeric>$4,200.00</Td></Tr> </TableBody></Table>Examples
Overview
| Invoice | Customer | Issued | Status | Amount |
|---|---|---|---|---|
| INV-2041 | Fernhill Studio | Mar 02, 2026 | Paid | $4,200.00 |
| INV-2042 | Quillmark | Mar 04, 2026 | Pending | $1,180.50 |
| INV-2043 | Tidewell Logistics | Mar 09, 2026 | Overdue | $12,940.00 |
| INV-2044 | Copperleaf Dental | Mar 11, 2026 | Paid | $860.00 |
| INV-2045 | Halden Works | Mar 15, 2026 | Draft | $2,315.75 |
| Total | $21,496.25 | |||
<Table framed label="Invoices"> <TableCaption srOnly>March invoices</TableCaption> <TableHead> <tr> <Th>Invoice</Th> <Th>Customer</Th> <Th>Issued</Th> <Th>Status</Th> <Th numeric>Amount</Th> </tr> </TableHead> <TableBody> {invoices.map((inv) => ( <Tr key={inv.id}> <Td muted className="font-mono text-[12.5px]">{inv.id}</Td> <Td className="font-medium">{inv.customer}</Td> <Td muted>{inv.issued}</Td> <Td> <Badge tone={statusTone[inv.status]} dot>{inv.status}</Badge> </Td> <Td numeric>{inv.amount}</Td> </Tr> ))} </TableBody> <TableFoot> <tr> <Td colSpan={4}>Total</Td> <Td numeric>$21,496.25</Td> </tr> </TableFoot></Table>Basic
Without a frame the table sits flush in a card or page section. Rows highlight on hover by default.
| Name | Role | Last active |
|---|---|---|
| Ada Moreno | Owner | 2 min ago |
| Theo Lindqvist | Admin | 1 hour ago |
| Priya Raman | Member | Yesterday |
<Table> <thead> <tr> <Th>Name</Th> <Th>Role</Th> <Th align="right">Last active</Th> </tr> </thead> <tbody> <Tr> <Td>Ada Moreno</Td> <Td muted>Owner</Td> <Td align="right" muted>2 min ago</Td> </Tr> <Tr> <Td>Theo Lindqvist</Td> <Td muted>Admin</Td> <Td align="right" muted>1 hour ago</Td> </Tr> <Tr> <Td>Priya Raman</Td> <Td muted>Member</Td> <Td align="right" muted>Yesterday</Td> </Tr> </tbody></Table>Zebra rows, numbers and footer
Striped rows help the eye across wide rows of figures. numeric right-aligns cells and uses tabular monospaced digits so columns line up; TdHead makes the first cell a row header. A visible caption sits under the table.
| Region | Q1 | Q2 | Q3 | Q4 |
|---|---|---|---|---|
| North America | $182,400 | $201,950 | $214,300 | $236,120 |
| Europe | $141,020 | $150,880 | $149,300 | $171,640 |
| Asia Pacific | $96,310 | $104,220 | $118,940 | $127,560 |
| Latin America | $38,450 | $41,120 | $44,780 | $51,230 |
| Middle East & Africa | $22,910 | $25,300 | $27,640 | $30,180 |
| Total | $481,090 | $523,470 | $554,960 | $616,730 |
<Table framed striped density="compact" label="Revenue by region"> <TableCaption>Net revenue in USD, fiscal year 2025. Figures are fictional.</TableCaption> <TableHead> <tr> <Th>Region</Th> <Th numeric>Q1</Th> <Th numeric>Q2</Th> <Th numeric>Q3</Th> <Th numeric>Q4</Th> </tr> </TableHead> <TableBody> {regions.map((r) => ( <Tr key={r.region}> <TdHead>{r.region}</TdHead> <Td numeric>{usd(r.q1)}</Td> <Td numeric>{usd(r.q2)}</Td> <Td numeric>{usd(r.q3)}</Td> <Td numeric>{usd(r.q4)}</Td> </Tr> ))} </TableBody> <TableFoot> <tr> <Td>Total</Td> {(["q1", "q2", "q3", "q4"] as const).map((q) => ( <Td key={q} numeric>{usd(regions.reduce((a, r) => a + r[q], 0))}</Td> ))} </tr> </TableFoot></Table>Sticky header
Set stickyHeader with a maxHeight and the body scrolls under the header inside the frame.
| Time | Event | Object | Status |
|---|---|---|---|
| 09:41:12 | payout.paid | po_8Hc21 | 200 |
| 09:40:58 | invoice.sent | in_3Kd90 | 200 |
| 09:40:31 | invoice.finalized | in_3Kd90 | 200 |
| 09:39:47 | customer.updated | cu_7Pa12 | 200 |
| 09:38:05 | charge.failed | ch_1Lm44 | 402 |
| 09:37:22 | charge.succeeded | ch_1Lm43 | 200 |
| 09:36:10 | invoice.paid | in_3Kc88 | 200 |
| 09:35:02 | webhook.retry | wh_9Qe01 | 500 |
| 09:34:48 | customer.created | cu_7Pa13 | 201 |
| 09:33:15 | payout.created | po_8Hc21 | 200 |
| 09:32:51 | invoice.sent | in_3Kc88 | 200 |
| 09:31:40 | charge.succeeded | ch_1Lm42 | 200 |
<Table framed stickyHeader maxHeight={300} density="compact" label="Webhook events"> <TableHead> <tr> <Th>Time</Th> <Th>Event</Th> <Th>Object</Th> <Th numeric>Status</Th> </tr> </TableHead> <TableBody> {events.map(([time, event, object, status]) => ( <Tr key={time}> <Td muted className="font-mono text-[12.5px]">{time}</Td> <Td className="font-mono text-[12.5px]">{event}</Td> <Td muted className="font-mono text-[12.5px]">{object}</Td> <Td numeric muted> {status.startsWith("2") ? status : <span className="font-medium text-danger">{status}</span>} </Td> </Tr> ))} </TableBody></Table>Props
Table
framedbooleandefault: falseBordered, rounded frame on the surface color.stripedbooleandefault: falseZebra rows.hoverbooleandefault: trueHighlight the row under the pointer.density"compact" | "normal" | "relaxed"default: "normal"Row height: 36, 44 or 56 px.stickyHeaderbooleandefault: falseKeep the header in view while the body scrolls.maxHeightnumber | stringdefault: —Height of the scroll area; needed for a sticky header.labelstringdefault: —Names the scroll region and makes it focusable for keyboard scrolling.
Th, Td, TdHead, Tr
Th align / numeric"left" | "center" | "right" / booleandefault: "left"Header alignment; numeric right-aligns.Th scope"col" | "row"default: "col"Header scope.Td numericbooleandefault: —Right-aligned, monospaced tabular figures.Td mutedbooleandefault: —Secondary text color for dates and IDs.Td colSpannumberdefault: —Span several columns (totals, empty rows).Tr selectedbooleandefault: —Accent-tinted background for a selected row.
TableHead, TableBody, TableFoot, TableCaption
TableCaption srOnlybooleandefault: —Hide the caption visually when a heading already describes the table.classNamestringdefault: —Extra classes on the section.
Accessibility
- It is a real table element, so screen readers announce rows, columns and header cells as you move through it.
- Th uses scope="col" and TdHead uses scope="row", so every cell is read with its column and row headers.
- Give each table a caption (visible, or srOnly when a heading already says the same) so it can be found in a screen reader's table list.
- When a label is set, the horizontal scroll area becomes a focusable region, so keyboard users can scroll it with the arrow keys.
- Status cells use a badge with a word, never color alone.