Skip to content
Nana UI

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.

View .md Download
Name Role Status Joined Ada Lovelace Engineer Active 2026-01-04 Alan Turing Researcher Invited 2026-02-11

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 table

Lands as src/components/ui/table/table.tsx, styled with Tailwind classes that resolve to the design tokens. Requires Tailwind v4.

import {
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.

Classes you pass to the React and Svelte components are merged with tailwind-merge, so an override wins rather than fighting. See Using Tailwind.

The variant attribute sets the table’s styling — default, bordered, striped, or minimal. Add hoverable for row hover.

ItemQty Default128 Widgets64 ItemQty Bordered128 Widgets64 ItemQty Striped128 Widgets64 ItemQty Minimal128 Widgets64

The density attribute sets cell padding — compact, normal, or comfortable.

ItemQty Widgets128 Gadgets64

nana-table-footer is the <tfoot> group, for totals and summary rows. It stays below the body however the rows are sorted.

ItemAmount WidgetsKSh 1,280.00 GadgetsKSh 640.00 TotalKSh 1,920.00

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.

Item Qty Widgets128 Gadgets64 Sprockets512

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.

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.

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.

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.

Reference Amount Status Actions RCT-1041 KSh 12,800 Cleared View

The React and Svelte props, read from the components’ own types:

Table

PropType
variantTableVariant
densityTableDensity

Also accepts HTMLAttributes<HTMLTableElement>.

TableHead

PropType
sortSortDirection
onSort() => void

Also accepts ThHTMLAttributes<HTMLTableCellElement>.

The attribute names, events and CSS hooks below are the Web Components reference.

Properties

PropertyAttributeTypeDefaultDescription
collapsecollapsebooleanfalseStack rows into cards when the table is narrow, rather than scrolling sideways.
collapseAtcollapse-atnumber0Width, in px, at which stacking kicks in. Measured on the table, not the viewport.
collapseKeepcollapse-keepnumber0Leading cells a stacked card keeps before the rest go behind a toggle; 0 shows every cell.
densitydensityTableDensity'normal'Cell padding
emptyemptybooleanfalseShow 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.
hoverablehoverablebooleanfalseHighlight rows on hover
loadingloadingbooleanfalseReplace 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.
managedmanagedbooleanfalseLet 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.
maxHeightmax-heightstring | undefined—Constrain the body height (e.g. "24rem") so the table scrolls vertically. This is what makes sticky-header actually stick.
pagepagenumber0Zero-based page, only read when managed.
pageSizepage-sizenumber0Rows per page; 0 keeps them on one page.
queryquerystring''Free-text filter, matched against every column that has an identifier.
sizingsizingSizingMode'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.
skeletonRowsskeleton-rowsnumber4
stickyHeadersticky-headerbooleanfalseKeep the header visible while the body scrolls. Pair with max-height so the body actually scrolls.
variantvariantTableVariant'default'Visual style

Events

EventDescription
nana-mode-changeWhen the table crosses a responsive width; detail: { mode: TableMode }
nana-view-changeWhile managed, after the rows are ordered, filtered and paged; detail: { page: number, pageCount: number, total: number }

Slots

SlotDescription
(default)Table sections (header, body, footer, caption)
emptyFull-width placeholder shown when empty is set (e.g. a "no results" message). Renders below the header, spanning every column.

CSS parts

PartDescription
::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

PropertyDescription
--nana-table-border-colorGrid / divider colour
--nana-table-row-hoverRow hover background
--nana-table-stripeZebra-stripe background (striped variant)
--nana-table-header-bgHeader background colour
--nana-table-header-colorHeader text colour
--nana-table-radiusCorner radius of the table's frame. Set to 0 for a flush, full-bleed table.
--nana-table-frameWidth of the frame around the table. Set to 0 to remove it.
--nana-table-bw-bottomBottom border width; set by the table's variant
--nana-table-min-widthWidth the table refuses to shrink below before the wrapper scrolls horizontally
--nana-table-pad-xHorizontal cell padding; set by the table's density
--nana-table-pad-yVertical cell padding; set by the table's density
EventTypeDescription
nana-mode-changeCustomEventThe table crossed a responsive width.
nana-view-changeCustomEventWhile managed, after rows are ordered, filtered and paged.
SlotDescription
(default)Table sections (header, body, footer, caption)
emptyFull-width placeholder shown when empty is set (e.g. a “no results” message). Renders below the header, spanning every column.
VariableDescription
--nana-table-border-colorGrid / divider colour
--nana-table-row-hoverRow hover background
--nana-table-stripeZebra-stripe background (striped variant)
--nana-table-header-bgHeader background colour
--nana-table-header-colorHeader text colour
--nana-table-radiusCorner radius of the table’s frame. Set to 0 for a flush, full-bleed table.
--nana-table-frameWidth of the frame around the table. Set to 0 to remove it.
PartDescription
containerThe scroll container
tableThe native <table> element
captionThe table caption
cellA header or body cell
contentThe inner content wrapper
rowThe cells row
toggleThe chevron disclosure button
detail-rowThe detail row wrapper
detailThe full-width detail panel
skeletonThe skeleton-loader region shown while loading
emptyThe empty-state region
  • Keyboard accessible
  • Screen-reader labels
  • Focus-visible states
  • ARIA roles and states
  • WCAG 2.2 AA contrast
Explore Table interactively Every state, prop, and edge case — with live controls — in Storybook.
View in Storybook