Skip to content
Nizivo UI

Table

A native table in server-rendered parts, for orders, products and customers. Rows can link to a detail page as a whole, columns sort through the URL, and number columns line up. No client JS.

Server component, no client JS

Import

import { Table } from '@nizivo/react/table';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css
import '@nizivo/react/css/table.css';

Static or interactive

Server. Every part renders native table markup with no client JavaScript: Table.Container (a <div>, the border and the horizontal scroll), Table.Root (<table>), Table.Caption, Table.Header, Table.Body, Table.Footer, Table.Row, Table.Head (<th>), Table.Cell, Table.SortButton and Table.RowLink. It is a table, not a grid: arrow keys do not move between cells, and assistive technology reads it as a table. Row checkboxes for bulk actions are a subpath of their own, TableSelection, so a page that imports @nizivo/react/table loads no client module in any bundler. How to choose.

In a client app (a Vite or React Router single-page app, or a 'use client' file) it is an ordinary React component: event handlers, refs and state work as usual. In a client app.

Name every table

Give the table a Table.Caption. When a card heading already says the same words, keep the caption and pass visuallyHidden, or drop it and point the table at the heading with aria-labelledby.

<Table.Row interactive> with a Table.RowLink in it (usually the first cell, as Figma's Order Row draws the order number) makes the whole row the link's hit area and shows bg/hover on hover. Buttons and other links in the row stay above it. Pass the router's link with render={<Link href="/orders/1042" />}.

Sorting

In the URL (a Server Component, or any page whose sort should survive a reload or a shared link): put a Table.SortButton in each sortable Table.Head, with href to the same page sorted by that column (render={<Link href="…" />} for the router's link), and pass sort="ascending" or sort="descending" to the column the page is sorted by: its <th> gets aria-sort, and the arrow follows.

In state (a client component): with neither href nor render, Table.SortButton is a <button type="button"> for your own handler. Keep the column and direction in useState, sort the rows, and pass sort to the sorted column's Table.Head:

<Table.Head sort={column === 'name' ? direction : undefined}>
  <Table.SortButton onClick={() => sortBy('name')}>Product</Table.SortButton>
</Table.Head>

In a client app has the whole example, live.

Numbers and dates

numeric on a Table.Head and the Table.Cells under it end-aligns them in tabular figures (Numeric/M in cells). Format dates and money with Intl and an explicit timeZone, so the server and the browser print the same text.

Layout

Figma: a 12 px radius with a border/subtle stroke; the header on bg/subtle in Label/M; cells 12 / 20 px, a border/subtle line under each row. A table wider than its container scrolls inside it; when its rows hold no link or control, give Table.Container a label, which names it as a scrollable region and puts it in the Tab order, so a keyboard user can reach it and scroll it with the arrow keys (Safari and Firefox do not focus a scroller on their own). selected on a row paints bg/brand-subtle. Hooks: --nz-table-bg, --nz-table-border, --nz-table-radius, --nz-table-header-bg, --nz-table-cell-padding-block, --nz-table-cell-padding-inline, --nz-table-row-bg-hover, --nz-table-row-bg-selected.

Examples

Orders

08's Recent orders (Figma Order Row): each row links to its order through the order number, a stretched link, so the whole row is the hit area. The caption names the table and is hidden, because the card above it shows the same words.

Recent orders
OrderDateCustomerPaymentFulfilmentTotal
#1042
Maya Rao
PaidUnfulfilled$112.00
#1041
Jonah Okafor
PaidFulfilled$64.50
#1040
Priya Shah
PendingOn hold$238.00
#1039
Leo Martin
RefundedFulfilled$28.00
import type { JSX } from 'react';
import { Avatar } from '@nizivo/react/avatar';
import { Badge, type BadgeTone } from '@nizivo/react/badge';
import { Inline } from '@nizivo/react/inline';
import { Table } from '@nizivo/react/table';

interface Order {
  id: string;
  placed: string;
  customer: string;
  payment: [string, BadgeTone];
  fulfilment: [string, BadgeTone];
  total: number;
}

/** Fixed data, as a server would load it; times are UTC instants. */
const ORDERS: readonly Order[] = [
  {
    id: '1042',
    placed: '2026-10-14T09:12:00Z',
    customer: 'Maya Rao',
    payment: ['Paid', 'success'],
    fulfilment: ['Unfulfilled', 'warning'],
    total: 112,
  },
  {
    id: '1041',
    placed: '2026-10-14T08:47:00Z',
    customer: 'Jonah Okafor',
    payment: ['Paid', 'success'],
    fulfilment: ['Fulfilled', 'success'],
    total: 64.5,
  },
  {
    id: '1040',
    placed: '2026-10-13T17:05:00Z',
    customer: 'Priya Shah',
    payment: ['Pending', 'warning'],
    fulfilment: ['On hold', 'neutral'],
    total: 238,
  },
  {
    id: '1039',
    placed: '2026-10-13T11:30:00Z',
    customer: 'Leo Martin',
    payment: ['Refunded', 'neutral'],
    fulfilment: ['Fulfilled', 'success'],
    total: 28,
  },
];

// An explicit locale and time zone: the server and the browser print the same text (§16.3).
const WHEN = new Intl.DateTimeFormat('en-US', {
  month: 'short',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit',
  hourCycle: 'h23',
  timeZone: 'UTC',
});

const MONEY = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

export function TableOrdersExample(): JSX.Element {
  return (
    <Table.Container>
      <Table.Root>
        <Table.Caption visuallyHidden>Recent orders</Table.Caption>
        <Table.Header>
          <Table.Row>
            <Table.Head>Order</Table.Head>
            <Table.Head>Date</Table.Head>
            <Table.Head>Customer</Table.Head>
            <Table.Head>Payment</Table.Head>
            <Table.Head>Fulfilment</Table.Head>
            <Table.Head numeric>Total</Table.Head>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {ORDERS.map((order) => (
            <Table.Row key={order.id} interactive>
              <Table.Cell>
                <Table.RowLink href={`#order-${order.id}`}>#{order.id}</Table.RowLink>
              </Table.Cell>
              <Table.Cell>
                <time dateTime={order.placed}>{WHEN.format(new Date(order.placed))}</time>
              </Table.Cell>
              <Table.Cell>
                <Inline gap="2">
                  <Avatar name={order.customer} size="sm" decorative />
                  {order.customer}
                </Inline>
              </Table.Cell>
              <Table.Cell>
                <Badge tone={order.payment[1]}>{order.payment[0]}</Badge>
              </Table.Cell>
              <Table.Cell>
                <Badge tone={order.fulfilment[1]}>{order.fulfilment[0]}</Badge>
              </Table.Cell>
              <Table.Cell numeric>{MONEY.format(order.total)}</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table.Root>
    </Table.Container>
  );
}

Sort

Sorting through the URL: each column's label is a link to the same page sorted by it, and the sorted column's <th> carries aria-sort. The page reads ?sort= on the server; no client code.

Products, by price, highest first
ProductInventoryPrice
Linen apron8 in stock$42.00
Stoneware mug124 in stock$28.00
Ceramic coaster setSold out$18.50
import type { JSX } from 'react';
import { Table } from '@nizivo/react/table';

export function TableSortExample(): JSX.Element {
  return (
    <Table.Container>
      <Table.Root>
        <Table.Caption>Products, by price, highest first</Table.Caption>
        <Table.Header>
          <Table.Row>
            <Table.Head>
              <Table.SortButton href="?sort=name">Product</Table.SortButton>
            </Table.Head>
            <Table.Head>
              <Table.SortButton href="?sort=stock">Inventory</Table.SortButton>
            </Table.Head>
            <Table.Head numeric sort="descending">
              <Table.SortButton href="?sort=price">Price</Table.SortButton>
            </Table.Head>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          <Table.Row>
            <Table.Head scope="row">Linen apron</Table.Head>
            <Table.Cell>8 in stock</Table.Cell>
            <Table.Cell numeric>$42.00</Table.Cell>
          </Table.Row>
          <Table.Row>
            <Table.Head scope="row">Stoneware mug</Table.Head>
            <Table.Cell>124 in stock</Table.Cell>
            <Table.Cell numeric>$28.00</Table.Cell>
          </Table.Row>
          <Table.Row selected>
            <Table.Head scope="row">Ceramic coaster set</Table.Head>
            <Table.Cell>Sold out</Table.Cell>
            <Table.Cell numeric>$18.50</Table.Cell>
          </Table.Row>
        </Table.Body>
      </Table.Root>
    </Table.Container>
  );
}

Parts

Use the parts through the namespace (Table.Container) or by their flat names (TableContainer); both work from Server Components.

Table.Container

The table's box: the border, the radius and the horizontal scroll of a wide table.

Server component Renders data-slot="table-container" with the class nz-table-container. Props: TableContainerProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
labelstring–

Names the container as a scrollable region and puts it in the Tab order, so a keyboard can scroll a table wider than the screen (Safari and Firefox do not focus a scroller on their own). Give it when the table can scroll sideways and its rows hold no link or control.

renderRenderProp<Empty>–
  • Also takes every attribute of <div>. (ComponentPropsWithRef<"div">)

render takes an element, or, in client code, a function of the props and no state.

Table.Root

Server component Renders data-slot="table" with the class nz-table. Props: TableRootProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
renderRenderProp<Empty>–
  • Also takes every attribute of <table>. (ComponentPropsWithRef<"table">)

render takes an element, or, in client code, a function of the props and no state.

Table.Caption

The table's name. Give every table one: a caption, or aria-labelledby to a visible heading.

Server component Renders data-slot="table-caption" with the class nz-table-caption. Props: TableCaptionProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
renderRenderProp<Empty>–
visuallyHiddenbooleanfalse

Keeps the caption as the table's accessible name but hides it from sight, for a table whose card already shows a heading.

  • Also takes every attribute of <caption>. (ComponentPropsWithRef<"caption">)

render takes an element, or, in client code, a function of the props and no state.

Table.Header

Server component Renders data-slot="table-header" with the class nz-table-header. Props: TableSectionProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
renderRenderProp<Empty>–
  • Also takes every attribute of <tbody>. (ComponentPropsWithRef<"tbody">)

render takes an element, or, in client code, a function of the props and no state.

Table.Body

Server component Renders data-slot="table-body" with the class nz-table-body. Props: TableSectionProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
renderRenderProp<Empty>–
  • Also takes every attribute of <tbody>. (ComponentPropsWithRef<"tbody">)

render takes an element, or, in client code, a function of the props and no state.

Server component Renders data-slot="table-footer" with the class nz-table-footer. Props: TableSectionProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
renderRenderProp<Empty>–
  • Also takes every attribute of <tbody>. (ComponentPropsWithRef<"tbody">)

render takes an element, or, in client code, a function of the props and no state.

Table.Row

Server component Renders data-slot="table-row" with the class nz-table-row. Props: TableRowProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
interactivebooleanfalse

The whole row is the hit area of its Table.RowLink (a stretched link) and shows Figma's hover. Other controls in the row stay above the link.

renderRenderProp<TableRowState>–
selectedbooleanfalse

data-selected (never aria-selected inside a table, §16.2).

  • Also takes every attribute of <tr>. (ComponentPropsWithRef<"tr">)

render takes an element, or, in client code, a function of the props and this state (TableRowState):

NameTypeDefaultDescription
interactiverequiredboolean–
selectedrequiredboolean–

Table.Head

Server component Renders data-slot="table-head" with the class nz-table-head. Props: TableHeadProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
numericbooleanfalse

A column of numbers: end-aligned, tabular figures.

renderRenderProp<TableHeadState>–
scope"col" | "row" | "colgroup" | "rowgroup"'col'

row for a row header in the body.

sortTableSort
"ascending""descending"
–

The column's current sort: aria-sort and data-sort on the <th>.

  • Also takes every attribute of <th>; scope is redeclared above. (ComponentPropsWithRef<"th">)

render takes an element, or, in client code, a function of the props and this state (TableHeadState):

NameTypeDefaultDescription
numericrequiredboolean–
sortrequiredTableSort | undefined
"ascending""descending"
–

Table.Cell

Server component Renders data-slot="table-cell" with the class nz-table-cell. Props: TableCellProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
numericbooleanfalse

A number: end-aligned, tabular figures.

renderRenderProp<TableCellState>–
  • Also takes every attribute of <td>. (ComponentPropsWithRef<"td">)

render takes an element, or, in client code, a function of the props and this state (TableCellState):

NameTypeDefaultDescription
numericrequiredboolean–

Table.SortButton

The column's label as a control. Its arrow follows the sort of the Table.Head it sits in (up for ascending, down for descending, both ways when unsorted) through CSS, so it needs no client code; the <th>'s aria-sort tells assistive technology the current order.

Server component Renders data-slot="table-sort-button" with the class nz-table-sort-button. Props: TableSortButtonProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
hrefstring–

The same page sorted by this column, for example href="?sort=-total": the part renders an <a>, and the URL holds the state. With neither href nor render it is a <button type="button"> for your own handler.

renderRenderProp<Empty>–

The router's link, for example render={<Link href="?sort=-total" />}.

  • Also takes every attribute of <button>. (ComponentPropsWithRef<"button">)

render takes an element, or, in client code, a function of the props and no state.

A row's link, usually its first cell's text (Figma Order Row: the order number). In a row with interactive, it stretches over the whole row.

Server component Renders data-slot="table-row-link" with the class nz-table-row-link. Props: TableRowLinkProps. Source: src/table/table.tsx.

NameTypeDefaultDescription
renderRenderProp<Empty>–

The router's link, for example render={<Link href="/orders/1042" />}. It must render an <a>.

  • Also takes every attribute of <a>. (ComponentPropsWithRef<"a">)

render takes an element, or, in client code, a function of the props and no state.

Other exports

TableCaptionProps type

import type { TableCaptionProps } from '@nizivo/react/table'

The props of Table.Caption, listed above.

TableCellProps type

import type { TableCellProps } from '@nizivo/react/table'

The props of Table.Cell, listed above.

TableCellState type

import type { TableCellState } from '@nizivo/react/table'

The render state of Table.Cell, listed above.

TableContainerProps type

import type { TableContainerProps } from '@nizivo/react/table'

The props of Table.Container, listed above.

TableHeadProps type

import type { TableHeadProps } from '@nizivo/react/table'

The props of Table.Head, listed above.

TableHeadState type

import type { TableHeadState } from '@nizivo/react/table'

The render state of Table.Head, listed above.

TableRootProps type

import type { TableRootProps } from '@nizivo/react/table'

The props of Table.Root, listed above.

TableRowLinkProps type

import type { TableRowLinkProps } from '@nizivo/react/table'

The props of Table.RowLink, listed above.

TableRowProps type

import type { TableRowProps } from '@nizivo/react/table'

The props of Table.Row, listed above.

TableRowState type

import type { TableRowState } from '@nizivo/react/table'

The render state of Table.Row, listed above.

TableSectionProps type

import type { TableSectionProps } from '@nizivo/react/table'

The props of Table.Body, listed above.

TableSort type

import type { TableSort } from '@nizivo/react/table'

type TableSort = "ascending" | "descending"

TableSortButtonProps type

import type { TableSortButtonProps } from '@nizivo/react/table'

The props of Table.SortButton, listed above.

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
table
.nz-table
Table.Root
root (takes render)
none
table-body
.nz-table-body
Table.Body
root (takes render)
none
table-caption
.nz-table-caption
Table.Caption
root (takes render)
none
table-cell
.nz-table-cell
Table.Cell
root (takes render)
  • data-numeric (present or absent) ← numeric
    The column holds numbers: end-aligned, with tabular figures
table-container
.nz-table-container
Table.Container
root (takes render)
none
table-footer
.nz-table-footer
Table.Footer
root (takes render)
none
table-head
.nz-table-head
Table.Head
root (takes render)
  • data-numeric (present or absent) ← numeric
    The column holds numbers: end-aligned, with tabular figures
  • data-sort: "ascending""descending" ← sort
    Current sort direction of the column
table-header
.nz-table-header
Table.Header
root (takes render)
none
table-row
.nz-table-row
Table.Row
root (takes render)
  • data-interactive (present or absent) ← interactive
    The whole surface is interactive
  • data-selected (present or absent) ← selected
    Selected
table-row-link
.nz-table-row-link
Table.RowLink
root (takes render); interactive, focus: self
none
table-sort-button
.nz-table-sort-button
Table.SortButton
root (takes render); interactive, focus: self
none
table-sort-icon
.nz-table-sort-icon
Table.SortButton
inner element (CSS only)
none

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-table-bg--nz-bg-surface
  • background on .nz-table-container
--nz-table-border--nz-border-subtle
  • border-block-end on .nz-table-cell
  • border on .nz-table-container
  • border-block-start on .nz-table-footer .nz-table-cell
  • border-block-start on .nz-table-footer .nz-table-head
  • border-block-end on .nz-table-head
--nz-table-cell-padding-block--nz-space-3
  • padding-block on .nz-table-cell
  • padding-block on .nz-table-head
--nz-table-cell-padding-inline--nz-space-5
  • padding-inline on .nz-table-cell
  • padding-inline on .nz-table-head
--nz-table-header-bg--nz-bg-subtle
  • background on .nz-table-head
--nz-table-radius--nz-radius-md
  • border-radius on .nz-table-container
--nz-table-row-bg-hover--nz-bg-hover
  • background on .nz-table-row[data-interactive]:not([data-selected]):hover @media (hover: hover)
--nz-table-row-bg-selected--nz-bg-brand-subtle
  • background on .nz-table-row:has( > .nz-table-cell > .nz-table-select-row > .nz-table-select-row-input:checked)
  • background on .nz-table-row[data-selected]

Layers: nz.components. Classes: .nz-inline, .nz-table, .nz-table-body, .nz-table-caption, .nz-table-cell, .nz-table-container, .nz-table-footer, .nz-table-head, .nz-table-row, .nz-table-row-link, .nz-table-select-all, .nz-table-select-row, .nz-table-select-row-input, .nz-table-sort-button, .nz-table-sort-icon.

Tokens this stylesheet reads (23)

--nz-bg-brand-subtle, --nz-bg-hover, --nz-bg-subtle, --nz-bg-surface, --nz-border-focus, --nz-border-subtle, --nz-border-width-default, --nz-border-width-focus, --nz-font-body-m, --nz-font-label-m, --nz-font-label-m-strong, --nz-font-numeric-m, --nz-icon-primary, --nz-icon-secondary, --nz-radius-md, --nz-size-icon-sm, --nz-space-1, --nz-space-3, --nz-space-5, --nz-text-primary, --nz-text-secondary, --nz-tracking-body-m, --nz-tracking-label-m

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • table-row-link: draws the focus outline itself on keyboard focus.
  • table-sort-button: draws the focus outline itself on keyboard focus.

Keyboard

KeysAction
TabReaches each row's link, then any control in a row, then the sort links in the header, in document order. The table and its cells take no focus.
EnterOn a row's link, opens the row; on a sort link, loads the page sorted by that column.

Source: Native table elements and links; no engine, no grid role. Checked by the browser lane (test/browser/table.spec.tsx) in Chromium, Firefox and WebKit.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

On this page