Table
Overviewimport { SortDirection, Table, TableAlign, TableBody, TableCell, TableFooter } from '@nili/ui';This is the library’s own overview page, rendered live. Switch the language or the theme in the header and it follows.
Basic
A real table element, not divs with row roles. Screen readers give a real table a navigation mode — move by cell, hear the column header on every jump — and none of that survives the rewrite. label names it; a table with no name is announced as “table” and nothing else.
| Customer | Amount | Status |
|---|---|---|
| Acme Co. | 1,200 | paid |
| Globex | 480 | pending |
| Initech | 3,150 | overdue |
| Umbrella | 90 | paid |
import {
Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Badge,
} from "@nili/ui"
export function Example() {
return (
<Table label="Invoices">
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead align="end">Amount</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell header>{row.customer}</TableCell>
<TableCell align="end" numeric>{row.amount}</TableCell>
<TableCell>
<Badge appearance="light" color="green" dot>Paid</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Row height and caption
Two row heights — X-Large (64) and Large (48). A visible caption names the table, so label is not needed as well.
| Customer | Amount |
|---|---|
| Acme Co. | 1,200 |
| Globex | 480 |
| Initech | 3,150 |
import { Table } from "@nili/ui"
export function Example() {
return (
<>
<Table size="xl" label="Invoices">…</Table>
<Table size="lg" caption={t('sizes.caption')}>…</Table>
</>
)
}Cell priority
How loudly a column reads. leading is the row's identity, passive is metadata the eye should skip, and none drops the type styling entirely so the cell can host a control.
| Customer | Updated | Actions | |
|---|---|---|---|
A | billing@acme.com | 2 days ago | |
G | ap@globex.io | 2 days ago | |
I | finance@initech.dev | 2 days ago |
import { Table, TableCell } from "@nili/ui"
export function Example() {
return (
<TableRow>
<TableCell priority="leading" header>Acme Co.</TableCell>
<TableCell priority="regular">billing@acme.com</TableCell>
<TableCell priority="passive">Updated 2 days ago</TableCell>
<TableCell priority="none">
<CompactButton aria-label="More"><Icon icon={RiMore2Fill} /></CompactButton>
</TableCell>
</TableRow>
)
}Sorting
sortable renders the sort button and manages aria-sort on the cell. It lands on the sorted column only — “none” on every other column is noise a screen reader reads aloud.
| Initech | 3,150 |
|---|---|
| Acme Co. | 1,200 |
| Globex | 480 |
| Umbrella | 90 |
import { TableHead } from "@nili/ui"
export function Example() {
const [sort, setSort] = useState({ key: "amount", direction: "desc" })
return (
<TableHead
align="end"
sortable
sortDirection={sort.key === "amount" ? sort.direction : null}
sortLabel={t('sorting.byAmount')}
onSort={(direction) => setSort({ key: "amount", direction })}
>
Amount
</TableHead>
)
}Selection, sticky header and footer
selected highlights a row and adds aria-selected. interactive is only a hover affordance — a row whose onClick opens a record is invisible to a screen reader, so put a real link or button in a cell and let the row highlight follow it.
| Customer | Amount | |
|---|---|---|
| Acme Co. | 1,200 | |
| Globex | 480 | |
| Initech | 3,150 | |
| Umbrella | 90 | |
| 1 of 4 selected | 4,920 | |
import { Table, TableRow, TableHead, TableCell, Checkbox } from "@nili/ui"
export function Example() {
return (
<Table label="Invoices" stickyHeader>
<TableHeader sticky>
<TableRow>
<TableHead state="empty"><Checkbox aria-label="Select all" /></TableHead>
<TableHead>Customer</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow selected interactive>
<TableCell priority="none"><Checkbox aria-label="Select row" checked /></TableCell>
<TableCell header>Globex</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>4 invoices</TableCell>
</TableRow>
</TableFooter>
</Table>
)
}Table
extends ComponentPropsWithoutRef<'table'>
Other props
| Prop | Type | Default | Notes |
|---|---|---|---|
| children | ReactNode | — | |
| aria-label | string | — | |
| size | xl | lg | xl | |
| label | string | — | Accessible name. A table with no name is announced as "table" and nothing else, which is useless on a page with three of them. |
| caption | ReactNode | — | Visible caption, rendered above the table. Also names it. |
| stickyHeader | boolean | false | Keeps the header visible while the body scrolls. |
| scrollable | boolean | true | Wrap in a scrollable, keyboard-focusable region. On by default: a table that overflows horizontally is unreachable by keyboard otherwise. |
| scrollClassName | string | — | Class for the scroll wrapper. Use `className` for the `<table>`. |
TableCaption
internalTableCell
extends Omit< ComponentPropsWithoutRef<'td'>, 'align' >
Variant axes — from the component’s cva definition
| Prop | Type | Default | Notes |
|---|---|---|---|
| align | start | center | end | start | |
| priority | leading | regular | passive | none | regular | Figma's `Priority` — how loudly the column reads. `leading` is the row's identity, `passive` is metadata the eye should skip, `none` drops the type styling so the cell can host a control. |
| numeric | boolean | false | Tabular figures, so a column of numbers lines up. |
| size | xl | lg | xl |
Other props
| Prop | Type | Default | Notes |
|---|---|---|---|
| children | ReactNode | — | |
| aria-label | string | — | |
| header | boolean | false | Makes the cell a row header (`<th scope="row">`). The first column of a data table usually is one, and marking it lets a screen reader say which row it is reading. |
TableHead
extends Omit< ComponentPropsWithoutRef<'th'>, 'align' >
Variant axes — from the component’s cva definition
| Prop | Type | Default | Notes |
|---|---|---|---|
| align | start | center | end | start | |
| state | default | disabled | empty | default | Figma's header `State`. `empty` marks a header with no label — the checkbox column — so it is not announced as naming its column. |
| size | xl | lg | xl |
Other props
| Prop | Type | Default | Notes |
|---|---|---|---|
| children | ReactNode | — | |
| aria-label | string | — | |
| sortable | boolean | false | Renders a sort button and manages `aria-sort` on the cell. |
| sortDirection | desc | asc | — | Current direction, or `null` when this column is not the sort key. |
| onSort | (direction: SortDirection) => void | — | |
| sortLabel | string | — | Accessible name for the sort button, e.g. `` (c) => `Sort by ${c}` ``. |
TableHeader
Variant axes — from the component’s cva definition
| Prop | Type | Default | Notes |
|---|---|---|---|
| sticky | 'true' | 'false' | false |
TableRow
extends ComponentPropsWithoutRef<'tr'>
Variant axes — from the component’s cva definition
| Prop | Type | Default | Notes |
|---|---|---|---|
| selected | boolean | false | Highlights the row and adds `aria-selected`. |
| interactive | boolean | false | Hover affordance for a row that navigates. The click target must still be a real control *inside* the row — see the note in the component. |
Other props
| Prop | Type | Default | Notes |
|---|---|---|---|
| children | ReactNode | — | |
| aria-label | string | — |
TableScroll
internalTableSortButton
internalVariant axes — from the component’s cva definition
| Prop | Type | Default | Notes |
|---|---|---|---|
| align | start | center | end | start | |
| sorted | 'true' | 'false' | false |