Data Display & Media
Table
A composable, accessible data table. Compose it with nana-table-header, nana-table-body, nana-table-footer, nana-table-row, nana-table-head, nana-table-cell and nana-table-caption. Built on the CSS table model + surface tokens, so it lays out natively, scrolls horizontally on small screens, and follows the light/dark theme.
Preview
Section titled “Preview”<nana-table variant="striped" hoverable style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Name</nana-table-head>
<nana-table-head>Role</nana-table-head>
<nana-table-head>Status</nana-table-head>
<nana-table-head align="right">Joined</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Ada Lovelace</nana-table-cell>
<nana-table-cell>Engineer</nana-table-cell>
<nana-table-cell>Active</nana-table-cell>
<nana-table-cell align="right">2026-01-04</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Alan Turing</nana-table-cell>
<nana-table-cell>Researcher</nana-table-cell>
<nana-table-cell>Invited</nana-table-cell>
<nana-table-cell align="right">2026-02-11</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table> Installation
Section titled “Installation”The table ships for all three targets. Pick the tab for your project — the CLI detects which you are on and serves the matching implementation.
npx @nana-tec/ui add tableLands as src/components/ui/table/table.tsx, styled with Tailwind classes that
resolve to the design tokens. Requires Tailwind v4.
npx @nana-tec/ui add tableLands in src/lib/components/ui/table/ as Table.svelte, TableHeader.svelte, TableBody.svelte, TableFooter.svelte, TableRow.svelte, TableHead.svelte, TableCell.svelte and TableCaption.svelte, styled with Tailwind
classes that resolve to the design tokens. Requires Tailwind v4.
Install the package for a versioned dependency:
npm install @nana-tec/ui-componentsOr copy the source in and own it:
npx @nana-tec/ui add tableimport { Table, TableHeader, TableBody, TableRow, TableHead, TableCell,} from "@/components/ui/table";
export function Example() { return ( <Table variant="striped" density="compact"> <TableHeader> <TableRow> <TableHead sort="asc" onSort={() => toggle("ref")}>Ref</TableHead> <TableHead>Customer</TableHead> </TableRow> </TableHeader> <TableBody> {orders.map((order) => ( <TableRow key={order.id}> <TableCell>{order.ref}</TableCell> <TableCell>{order.customer}</TableCell> </TableRow> ))} </TableBody> </Table> );}These are real <table> elements, so semantics and screen-reader behaviour come
from the platform. variant and density are shared with every row and cell
through context. Giving a TableHead an onSort renders a sort button and sets
aria-sort from sort.
<script lang="ts"> import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, } from '$lib/components/ui/table';</script>
<Table variant="striped" density="compact"> <TableHeader> <TableRow> <TableHead sort="asc" onsort={() => toggle('ref')}>Ref</TableHead> <TableHead>Customer</TableHead> </TableRow> </TableHeader> <TableBody> {#each orders as order (order.id)} <TableRow> <TableCell>{order.ref}</TableCell> <TableCell>{order.customer}</TableCell> </TableRow> {/each} </TableBody></Table>These are real <table> elements. variant and density are shared with every
row and cell through context. Giving a TableHead an onsort renders a sort
button and sets aria-sort from sort.
import "@nana-tec/ui-components/table";<nana-table variant="striped" hoverable> <nana-table-header> <nana-table-row> <nana-table-head>Name</nana-table-head> <nana-table-head>Role</nana-table-head> </nana-table-row> </nana-table-header> <nana-table-body> <nana-table-row> <nana-table-cell>Ada Lovelace</nana-table-cell> <nana-table-cell>Engineer</nana-table-cell> </nana-table-row> </nana-table-body></nana-table>Classes you pass to the React and Svelte components are merged with
tailwind-merge, so an override wins rather than fighting. See
Using Tailwind.
Variants
Section titled “Variants”The variant attribute sets the table’s styling — default, bordered,
striped, or minimal. Add hoverable for row hover.
<nana-table variant="default" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Item</nana-table-head>
<nana-table-head align="right">Qty</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Default</nana-table-cell>
<nana-table-cell align="right">128</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell align="right">64</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table>
<nana-table variant="bordered" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Item</nana-table-head>
<nana-table-head align="right">Qty</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Bordered</nana-table-cell>
<nana-table-cell align="right">128</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell align="right">64</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table>
<nana-table variant="striped" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Item</nana-table-head>
<nana-table-head align="right">Qty</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Striped</nana-table-cell>
<nana-table-cell align="right">128</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell align="right">64</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table>
<nana-table variant="minimal" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Item</nana-table-head>
<nana-table-head align="right">Qty</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Minimal</nana-table-cell>
<nana-table-cell align="right">128</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell align="right">64</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table> Density
Section titled “Density”The density attribute sets cell padding — compact, normal, or
comfortable.
<nana-table density="compact" variant="bordered" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Item</nana-table-head>
<nana-table-head align="right">Qty</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell align="right">128</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Gadgets</nana-table-cell>
<nana-table-cell align="right">64</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table> Footer
Section titled “Footer”nana-table-footer is the <tfoot> group, for totals and summary rows. It stays
below the body however the rows are sorted.
<nana-table variant="bordered" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head>Item</nana-table-head>
<nana-table-head numeric>Amount</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell numeric>KSh 1,280.00</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Gadgets</nana-table-cell>
<nana-table-cell numeric>KSh 640.00</nana-table-cell>
</nana-table-row>
</nana-table-body>
<nana-table-footer>
<nana-table-row>
<nana-table-cell><strong>Total</strong></nana-table-cell>
<nana-table-cell numeric><strong>KSh 1,920.00</strong></nana-table-cell>
</nana-table-row>
</nana-table-footer>
</nana-table> Sorting, filtering and paging
Section titled “Sorting, filtering and paging”By default the table only renders: the rows it shows are the rows you give it.
Add managed and it applies the headless table rules to its own rows — ordering
on the header you activate, filtering on query, and paging on page-size.
Give each sortable nana-table-head an identifier; that is the key the rules
read, and the cell in the same column position supplies the value.
<nana-table managed variant="bordered" style="width: 100%">
<nana-table-header>
<nana-table-row>
<nana-table-head sortable identifier="item">Item</nana-table-head>
<nana-table-head sortable identifier="qty" align="right">
Qty
</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>Widgets</nana-table-cell>
<nana-table-cell align="right">128</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Gadgets</nana-table-cell>
<nana-table-cell align="right">64</nana-table-cell>
</nana-table-row>
<nana-table-row>
<nana-table-cell>Sprockets</nana-table-cell>
<nana-table-cell align="right">512</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table> Numbers compare as numbers, so Qty orders 64 before 128 rather than
lexically. A third activation returns the column to the order you wrote.
query filters across every identified column, page-size splits the rest into
pages, and nana-view-change reports { page, pageCount, total } after each
pass so a nana-pagination can follow it.
Without managed nothing changes: nana-sort still fires with the direction
the header shows and the one it should move to, and you apply it yourself.
Stacking into cards
Section titled “Stacking into cards”collapse turns rows into cards once the table is narrow, showing every cell.
The threshold is the table’s own width, not the screen’s — a table in a
narrow sidebar stacks on a desktop too. collapse-at moves it.
That also means a min-width on the table or any ancestor holds it open: the
phone is narrow but the table is not, so it keeps scrolling sideways instead of
stacking. Drop the floor at narrow widths, or raise collapse-at past it. The
component warns in the console when it spots that combination.
Choosing what the card shows
Section titled “Choosing what the card shows”Mark the columns worth seeing at a glance with summary, and the rest go behind
a toggle. collapse-keep="N" takes the first N instead. With neither, the card
shows everything and has no toggle.
Row actions
Section titled “Row actions”Mark the actions column with actions on its head. On a desktop it is an
ordinary column; on a card it drops to the foot as a button row, without the
label a data cell gets, whatever column it occupies.
Give that head summary as well to keep the actions on the collapsed face;
leave it off and they appear when the card opens.
<nana-table collapse collapse-keep="2" style="max-width: 22rem">
<nana-table-header>
<nana-table-row>
<nana-table-head summary>Reference</nana-table-head>
<nana-table-head summary align="right">Amount</nana-table-head>
<nana-table-head>Status</nana-table-head>
<nana-table-head actions>Actions</nana-table-head>
</nana-table-row>
</nana-table-header>
<nana-table-body>
<nana-table-row>
<nana-table-cell>RCT-1041</nana-table-cell>
<nana-table-cell align="right">KSh 12,800</nana-table-cell>
<nana-table-cell>Cleared</nana-table-cell>
<nana-table-cell>
<nana-button size="sm" variant="secondary">View</nana-button>
</nana-table-cell>
</nana-table-row>
</nana-table-body>
</nana-table> The React and Svelte props, read from the components’ own types:
Table
| Prop | Type |
|---|---|
variant | TableVariant |
density | TableDensity |
Also accepts HTMLAttributes<HTMLTableElement>.
TableHead
| Prop | Type |
|---|---|
sort | SortDirection |
onSort | () => void |
Also accepts ThHTMLAttributes<HTMLTableCellElement>.
Table
| Prop | Type | Default |
|---|---|---|
variant | TableVariant | 'default' |
density | TableDensity | 'normal' |
children | Snippet |
Also accepts Omit<HTMLTableAttributes, 'class'>.
TableHead
| Prop | Type |
|---|---|
sort | SortDirection |
onsort | () => void |
children | Snippet |
Also accepts Omit<HTMLThAttributes, 'class'>.
The attribute names, events and CSS hooks below are the Web Components reference.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
collapse | collapse | boolean | false | Stack rows into cards when the table is narrow, rather than scrolling sideways. |
collapseAt | collapse-at | number | 0 | Width, in px, at which stacking kicks in. Measured on the table, not the viewport. |
collapseKeep | collapse-keep | number | 0 | Leading cells a stacked card keeps before the rest go behind a toggle; 0 shows every cell. |
density | density | TableDensity | 'normal' | Cell padding |
empty | empty | boolean | false | Show the full-width empty-state placeholder instead of body rows. Provide the message via slot="empty". The header still renders so the columns stay in context. A single nana-table-cell can't span columns (CSS tables have no colspan), so the placeholder is rendered as a full-width block below the grid. |
hoverable | hoverable | boolean | false | Highlight rows on hover |
loading | loading | boolean | false | Replace the body rows with an animated skeleton loader while data loads. The header stays visible so the layout doesn't jump when the rows arrive. |
managed | managed | boolean | false | Let the table order, filter and page its own rows. Without it the table only renders, and the consumer decides which rows exist — the behaviour every version before this one had. |
maxHeight | max-height | string | undefined | — | Constrain the body height (e.g. "24rem") so the table scrolls vertically. This is what makes sticky-header actually stick. |
page | page | number | 0 | Zero-based page, only read when managed. |
pageSize | page-size | number | 0 | Rows per page; 0 keeps them on one page. |
query | query | string | '' | Free-text filter, matched against every column that has an identifier. |
sizing | sizing | SizingMode | 'fill' | Column sizing strategy. - fill: Table fills the container width; columns auto-size (default). - content: Columns size to their intrinsic content; table scrolls horizontally if wider than the container. - fixed: Declared column widths are honoured; required for reliable truncation. |
skeletonRows | skeleton-rows | number | 4 | |
stickyHeader | sticky-header | boolean | false | Keep the header visible while the body scrolls. Pair with max-height so the body actually scrolls. |
variant | variant | TableVariant | 'default' | Visual style |
Events
| Event | Description |
|---|---|
nana-mode-change | When the table crosses a responsive width; detail: { mode: TableMode } |
nana-view-change | While managed, after the rows are ordered, filtered and paged; detail: { page: number, pageCount: number, total: number } |
Slots
| Slot | Description |
|---|---|
(default) | Table sections (header, body, footer, caption) |
empty | Full-width placeholder shown when empty is set (e.g. a "no results" message). Renders below the header, spanning every column. |
CSS parts
| Part | Description |
|---|---|
::part(container) | The scroll container |
::part(table) | The native <table> element |
::part(cell) | A header or body cell |
::part(caption) | The table caption |
::part(skeleton) | The skeleton-loader region shown while loading |
::part(empty) | The empty-state region |
CSS custom properties
| Property | Description |
|---|---|
--nana-table-border-color | Grid / divider colour |
--nana-table-row-hover | Row hover background |
--nana-table-stripe | Zebra-stripe background (striped variant) |
--nana-table-header-bg | Header background colour |
--nana-table-header-color | Header text colour |
--nana-table-radius | Corner radius of the table's frame. Set to 0 for a flush, full-bleed table. |
--nana-table-frame | Width of the frame around the table. Set to 0 to remove it. |
--nana-table-bw-bottom | Bottom border width; set by the table's variant |
--nana-table-min-width | Width the table refuses to shrink below before the wrapper scrolls horizontally |
--nana-table-pad-x | Horizontal cell padding; set by the table's density |
--nana-table-pad-y | Vertical cell padding; set by the table's density |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
nana-mode-change | CustomEvent | The table crossed a responsive width. |
nana-view-change | CustomEvent | While managed, after rows are ordered, filtered and paged. |
| Slot | Description |
|---|---|
(default) | Table sections (header, body, footer, caption) |
empty | Full-width placeholder shown when empty is set (e.g. a “no results” message). Renders below the header, spanning every column. |
CSS Variables
Section titled “CSS Variables”| Variable | Description |
|---|---|
--nana-table-border-color | Grid / divider colour |
--nana-table-row-hover | Row hover background |
--nana-table-stripe | Zebra-stripe background (striped variant) |
--nana-table-header-bg | Header background colour |
--nana-table-header-color | Header text colour |
--nana-table-radius | Corner radius of the table’s frame. Set to 0 for a flush, full-bleed table. |
--nana-table-frame | Width of the frame around the table. Set to 0 to remove it. |
CSS Parts
Section titled “CSS Parts”| Part | Description |
|---|---|
container | The scroll container |
table | The native <table> element |
caption | The table caption |
cell | A header or body cell |
content | The inner content wrapper |
row | The cells row |
toggle | The chevron disclosure button |
detail-row | The detail row wrapper |
detail | The full-width detail panel |
skeleton | The skeleton-loader region shown while loading |
empty | The empty-state region |
Accessibility
Section titled “Accessibility”- Keyboard accessible
- Screen-reader labels
- Focus-visible states
- ARIA roles and states
- WCAG 2.2 AA contrast