Skip to content
cennge-ui
GitHubv0.3.1, changelog

Table

Rows and columns in real table markup, with sortable headers, checkbox selection, and TanStack Table when you want it.

5 workflow runs

cennge-ui#214 on mainPassed6m 52s
payload-cache#88 on fix/ttlFailed1m 36s
spredo-crypto-screener#131 on mainRunning—
ai-niche-scout#57 on mainPassed3m 58s
playwright-msi-scraper#23 on depsFailed2m 51s
  1. Click a header, or press Enter on it: ascending, then descending. A run still going has no duration and stays last.
  2. Tick runs; the header box shows a partial selection. Re-run sends the ticked ones back to running.
  3. On a narrow screen the table scrolls sideways and takes Tab, so the arrows scroll it.
Live: the component itself, not a picture of it.

Field marks

Import
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from "cennge-ui";
Size
2.4 KB gzip for this import, its dependencies aside
Pattern
Native <table>; sortable headers carry aria-sort
Keyboard
Enter Space
State
Yours: useTableSort and useTableSelection hold the sort and the selection, or TanStack Table does
SSR
Rendered on the server in the Next.js check; hydrates without a warning
TanStack Table
No adapter: TableColumn takes getIsSorted() and getToggleSortingHandler() as they are
Too wide
Scrolls sideways as a named region that takes Tab
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: table

When to use#

Use a Table when people compare records across the same columns: workflow runs, deploys, releases. It is the HTML table with styles and a few states on top, so screen readers move through it by row and by column and hear each cell's header on the way.

The Table renders every row you give it and has no paging of its own. Split long data into pages with Pagination, as two of the examples below do.

Similar#

  • VirtualList keeps thousands of rows in one scrolling box by rendering only those in view. It is a list: no columns, no headers.
  • Tree is for rows inside rows, opened and closed with the arrow keys.
  • A table in Markdown or MDX content takes its look from Prose; Table is for data you render yourself.

Sorting on the client#

useTableSort(rows, { getValue }) returns sortedRows and getSortProps(column) to spread on a TableColumn. getValue gives a row's value in a column: numbers and dates compare as such, text by the locale (locale, else the provider's) with digits read as numbers, so v1.10.0 sorts after v1.9.2. Empty values go last in both directions. defaultSort sets the order before the first click.

ai-niche-scoutv2.4.0Nov 7, 16:05Running
payload-cachev1.10.0Nov 6, 09:1284 s
spredo-crypto-screenerv0.9.0Nov 4, 11:50203 s
cennge-uiv0.3.1Nov 1, 08:40131 s
payload-cachev1.9.2Oct 29, 13:2779 s
sorting.tsx
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, useTableSort } from "cennge-ui";

interface Deploy {
  repository: string;
  version: string;
  started: Date;
  /** Null while the deploy is still running. */
  seconds: number | null;
}

const deploys: Deploy[] = [
  { repository: "payload-cache", version: "v1.10.0", started: new Date("2026-11-06T09:12Z"), seconds: 84 },
  { repository: "cennge-ui", version: "v0.3.1", started: new Date("2026-11-01T08:40Z"), seconds: 131 },
  { repository: "ai-niche-scout", version: "v2.4.0", started: new Date("2026-11-07T16:05Z"), seconds: null },
  { repository: "payload-cache", version: "v1.9.2", started: new Date("2026-10-29T13:27Z"), seconds: 79 },
  { repository: "spredo-crypto-screener", version: "v0.9.0", started: new Date("2026-11-04T11:50Z"), seconds: 203 },
];

type Column = keyof Deploy;

const date = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" });

export default function DeploysTable() {
  const { sortedRows, getSortProps } = useTableSort(deploys, {
    getValue: (deploy, column: Column) => deploy[column],
    defaultSort: { column: "started", direction: "descending" },
  });

  return (
    <Table aria-label="Deploys">
      <TableHeader>
        <TableColumn {...getSortProps("repository")}>Repository</TableColumn>
        <TableColumn {...getSortProps("version")}>Version</TableColumn>
        <TableColumn {...getSortProps("started")}>Started, UTC</TableColumn>
        <TableColumn {...getSortProps("seconds")} align="end">
          Took
        </TableColumn>
      </TableHeader>
      <TableBody>
        {sortedRows.map((deploy) => (
          <TableRow key={deploy.repository + deploy.version} className="whitespace-nowrap">
            <TableCell className="font-medium">{deploy.repository}</TableCell>
            <TableCell className="font-mono text-[13px]">{deploy.version}</TableCell>
            <TableCell className="tabular-nums">{date.format(deploy.started)}</TableCell>
            <TableCell align="end" className="tabular-nums">
              {deploy.seconds === null ? "Running" : `${deploy.seconds} s`}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Selection across pages#

useTableSelection(keys) takes the keys of the rows on screen. The header checkbox reads allSelected and someSelected for those rows only, while selected, a Set, keeps rows ticked on other pages. isSelected on a TableRow only tints it; the checkbox in the row is what screen readers hear.

10 preview deployments

BranchRepository
docs/tablecennge-ui
feat/tree-lazycennge-ui
fix/ttlpayload-cache
feat/redis-adapterpayload-cache
selection.tsx
import { useState } from "react";
import {
  Button,
  Checkbox,
  Pagination,
  Table,
  TableBody,
  TableCell,
  TableColumn,
  TableHeader,
  TableRow,
  useTableSelection,
} from "cennge-ui";

interface Preview {
  id: string;
  repository: string;
  branch: string;
}

const initialPreviews: Preview[] = [
  { id: "p1", repository: "cennge-ui", branch: "docs/table" },
  { id: "p2", repository: "cennge-ui", branch: "feat/tree-lazy" },
  { id: "p3", repository: "payload-cache", branch: "fix/ttl" },
  { id: "p4", repository: "payload-cache", branch: "feat/redis-adapter" },
  { id: "p5", repository: "ai-niche-scout", branch: "feat/keyword-score" },
  { id: "p6", repository: "ai-niche-scout", branch: "chore/deps" },
  { id: "p7", repository: "spredo-crypto-screener", branch: "feat/funding-rates" },
  { id: "p8", repository: "spredo-crypto-screener", branch: "fix/ws-reconnect" },
  { id: "p9", repository: "playwright-msi-scraper", branch: "feat/retries" },
  { id: "p10", repository: "playwright-msi-scraper", branch: "chore/playwright-update" },
];

const pageSize = 4;

export default function PreviewCleanup() {
  const [previews, setPreviews] = useState(initialPreviews);
  const [page, setPage] = useState(1);
  const pages = Math.max(1, Math.ceil(previews.length / pageSize));
  const current = Math.min(page, pages);
  const onScreen = previews.slice((current - 1) * pageSize, current * pageSize);
  // "All" means the rows on this page; the selection keeps rows ticked on other pages.
  const selection = useTableSelection(onScreen.map((preview) => preview.id));
  const count = selection.selected.size;

  const remove = () => {
    setPreviews((list) => list.filter((preview) => !selection.isSelected(preview.id)));
    selection.clear();
  };

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex min-h-8 items-center justify-between gap-3">
        <p className="text-sm text-muted" aria-live="polite">
          {count ? `${count} selected across pages` : `${previews.length} preview deployments`}
        </p>
        <Button size="sm" variant="flat" color="danger" isDisabled={!count} onClick={remove}>
          Delete
        </Button>
      </div>
      <Table aria-label="Preview deployments">
        <TableHeader>
          <TableColumn className="w-10">
            <Checkbox
              aria-label="Select all on this page"
              isSelected={selection.allSelected}
              isIndeterminate={selection.someSelected}
              onValueChange={selection.toggleAll}
            />
          </TableColumn>
          <TableColumn>Branch</TableColumn>
          <TableColumn>Repository</TableColumn>
        </TableHeader>
        <TableBody emptyContent="No preview deployments left">
          {onScreen.map((preview) => (
            <TableRow key={preview.id} isSelected={selection.isSelected(preview.id)} className="whitespace-nowrap">
              <TableCell>
                <Checkbox
                  aria-label={`Select ${preview.branch}`}
                  isSelected={selection.isSelected(preview.id)}
                  onValueChange={() => selection.toggle(preview.id)}
                />
              </TableCell>
              <TableCell className="font-mono text-[13px]">{preview.branch}</TableCell>
              <TableCell className="text-muted">{preview.repository}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <Pagination aria-label="Preview pages" total={pages} page={current} onPageChange={setPage} className="self-center" />
    </div>
  );
}

Loading and empty#

isLoading dims the rows already shown and sets aria-busy on the table. With no rows yet, the body shows loadingContent, a spinner by default; with no rows and no loading, emptyContent. Both span every column. Choose Failed to go through both, and Refresh to dim the rows in place.

JobResult
Typecheck, test, buildPassed
Playwright · chromiumPassed
Playwright · firefoxPassed
Playwright · webkitPassed
states.tsx
import { useRef, useState } from "react";
import {
  Badge,
  Button,
  EmptyState,
  Table,
  TableBody,
  TableCell,
  TableColumn,
  TableHeader,
  TableRow,
  ToggleGroup,
  ToggleGroupItem,
} from "cennge-ui";

interface Run {
  job: string;
  passed: boolean;
}

const runs: Run[] = [
  { job: "Typecheck, test, build", passed: true },
  { job: "Playwright · chromium", passed: true },
  { job: "Playwright · firefox", passed: true },
  { job: "Playwright · webkit", passed: true },
];

type Filter = "all" | "failed";

// Stands in for the request.
const fetchRuns = (filter: Filter) =>
  new Promise<Run[]>((resolve) => setTimeout(() => resolve(filter === "failed" ? runs.filter((run) => !run.passed) : runs), 900));

export default function RunsWithStates() {
  const [filter, setFilter] = useState<Filter>("all");
  const [rows, setRows] = useState(runs);
  const [isLoading, setLoading] = useState(false);
  const request = useRef(0);

  const load = async (next: Filter, keepRows: boolean) => {
    const id = ++request.current;
    setFilter(next);
    setLoading(true);
    if (!keepRows) setRows([]);
    const result = await fetchRuns(next);
    if (id !== request.current) return;
    setRows(result);
    setLoading(false);
  };

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <ToggleGroup aria-label="Show" size="sm" value={filter} onValueChange={(value) => value && load(value as Filter, false)}>
          <ToggleGroupItem value="all">All jobs</ToggleGroupItem>
          <ToggleGroupItem value="failed">Failed</ToggleGroupItem>
        </ToggleGroup>
        <Button size="sm" variant="flat" isDisabled={isLoading} onClick={() => load(filter, true)}>
          Refresh
        </Button>
      </div>
      <Table aria-label="Jobs of cennge-ui run 214" isLoading={isLoading}>
        <TableHeader>
          <TableColumn>Job</TableColumn>
          <TableColumn align="end">Result</TableColumn>
        </TableHeader>
        <TableBody
          emptyContent={<EmptyState size="sm" title="No failed jobs" description="Every job in this run passed." />}
        >
          {rows.map((run) => (
            <TableRow key={run.job}>
              <TableCell>{run.job}</TableCell>
              <TableCell align="end">
                <Badge size="sm" color={run.passed ? "success" : "danger"}>
                  {run.passed ? "Passed" : "Failed"}
                </Badge>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

A fixed height with the header in view#

Give the wrapper a height through classNames.wrapper and set isHeaderSticky: the rows scroll under the header, and its sort buttons stay in reach. Click Took twice to put the slowest job first. size="sm" makes the rows compact and isStriped tints every other one.

Branch
#214Typecheck, test, buildmain40s
#214React 17main1m 33s
#214React 18main2m 26s
#214React 19main3m 19s
#214Next.js · server rendering and hydrationmain4m 12s
#214Playwright · chromiummain45s
#214Playwright · firefoxmain1m 38s
#214Playwright · webkitmain2m 31s
#213Typecheck, test, builddocs/table47s
#213React 17docs/table1m 40s
#213React 18docs/table2m 33s
#213React 19docs/table3m 26s
#213Next.js · server rendering and hydrationdocs/table4m 19s
#213Playwright · chromiumdocs/table52s
#213Playwright · firefoxdocs/table1m 45s
#213Playwright · webkitdocs/table2m 38s
#212Typecheck, test, buildmain54s
#212React 17main1m 47s
#212React 18main2m 40s
#212React 19main3m 33s
#212Next.js · server rendering and hydrationmain4m 26s
#212Playwright · chromiummain59s
#212Playwright · firefoxmain1m 52s
#212Playwright · webkitmain2m 45s
#211Typecheck, test, builddocs/table1m 1s
#211React 17docs/table1m 54s
#211React 18docs/table2m 47s
#211React 19docs/table3m 40s
#211Next.js · server rendering and hydrationdocs/table4m 33s
#211Playwright · chromiumdocs/table1m 6s
#211Playwright · firefoxdocs/table1m 59s
#211Playwright · webkitdocs/table2m 52s
sticky-header.tsx
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, useTableSort } from "cennge-ui";

const jobNames = [
  "Typecheck, test, build",
  "React 17",
  "React 18",
  "React 19",
  "Next.js · server rendering and hydration",
  "Playwright · chromium",
  "Playwright · firefox",
  "Playwright · webkit",
];

interface Job {
  id: string;
  run: number;
  name: string;
  branch: string;
  seconds: number;
}

const jobs: Job[] = Array.from({ length: 4 }, (_, run) =>
  jobNames.map((name, index) => ({
    id: `${214 - run}-${index}`,
    run: 214 - run,
    name,
    branch: run % 2 ? "docs/table" : "main",
    seconds: 40 + ((run * 7 + index * 53) % 260),
  })),
).flat();

const duration = (seconds: number) => (seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`);

type Column = "run" | "name" | "seconds";

export default function CiJobs() {
  const { sortedRows, getSortProps } = useTableSort(jobs, { getValue: (job, column: Column) => job[column] });

  return (
    <Table aria-label="CI jobs" size="sm" isStriped isHeaderSticky classNames={{ wrapper: "max-h-72" }}>
      <TableHeader>
        <TableColumn {...getSortProps("run")}>Run</TableColumn>
        <TableColumn {...getSortProps("name")}>Job</TableColumn>
        <TableColumn>Branch</TableColumn>
        <TableColumn {...getSortProps("seconds")} align="end">
          Took
        </TableColumn>
      </TableHeader>
      <TableBody>
        {sortedRows.map((job) => (
          <TableRow key={job.id} className="whitespace-nowrap">
            <TableCell className="tabular-nums">#{job.run}</TableCell>
            <TableCell>{job.name}</TableCell>
            <TableCell className="font-mono">{job.branch}</TableCell>
            <TableCell align="end" className="tabular-nums">
              {duration(job.seconds)}
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

With TanStack Table#

TanStack Table owns the sorting and the paging; cennge-ui draws the table. TableColumn reads column.getIsSorted(), which is "asc", "desc" or false, as it is, and getToggleSortingHandler() goes straight to onSort, so Shift+click adds a second sort. The order of clicks is TanStack's: text starts ascending, numbers descending, and a third click clears the sort.

cennge-ui#20045s
ai-niche-scout#1992m 22s
payload-cache#1983m 59s
spredo-crypto-screener#1975m 36s
playwright-msi-scraper#19653s
cennge-ui#1952m 30s
tanstack.tsx
import { useState } from "react";
import {
  createColumnHelper,
  createPaginatedRowModel,
  createSortedRowModel,
  rowPaginationFeature,
  rowSortingFeature,
  tableFeatures,
  useTable,
  type PaginationState,
  type SortingState,
} from "@tanstack/react-table";
import { Pagination, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "cennge-ui";

interface Run {
  repository: string;
  number: number;
  seconds: number;
}

const repositories = ["cennge-ui", "ai-niche-scout", "payload-cache", "spredo-crypto-screener", "playwright-msi-scraper"];
const data: Run[] = Array.from({ length: 24 }, (_, index) => ({
  repository: repositories[index % repositories.length]!,
  number: 200 - index,
  seconds: 45 + ((index * 97) % 380),
}));

const duration = (seconds: number) => (seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`);
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });

const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
});

const helper = createColumnHelper<typeof features, Run>();
const columns = helper.columns([
  helper.accessor("repository", {
    header: "Repository",
    sortFn: (a, b, id) => collator.compare(String(a.getValue(id)), String(b.getValue(id))),
  }),
  helper.accessor("number", {
    header: "Run",
    cell: (cell) => `#${cell.getValue()}`,
    sortFn: (a, b, id) => Number(a.getValue(id)) - Number(b.getValue(id)),
  }),
  helper.accessor("seconds", {
    header: "Took",
    cell: (cell) => duration(cell.getValue()),
    sortFn: (a, b, id) => Number(a.getValue(id)) - Number(b.getValue(id)),
  }),
]);

const numeric = new Set(["number", "seconds"]);

export default function RunsWithTanStack() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 6 });
  const table = useTable({
    features,
    columns,
    data,
    state: { sorting, pagination },
    onSortingChange: setSorting,
    onPaginationChange: setPagination,
  });
  const headers = table.getHeaderGroups()[0]?.headers ?? [];

  return (
    <div className="flex w-full flex-col gap-3">
      <Table aria-label="Recent workflow runs">
        <TableHeader>
          {headers.map((header) => (
            <TableColumn
              key={header.id}
              onSort={header.column.getToggleSortingHandler()}
              sortDirection={header.column.getIsSorted()}
              align={numeric.has(header.column.id) ? "end" : "start"}
            >
              <table.FlexRender header={header} />
            </TableColumn>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.map((row) => (
            <TableRow key={row.id} className="whitespace-nowrap">
              {row.getAllCells().map((cell) => (
                <TableCell key={cell.id} align={numeric.has(cell.column.id) ? "end" : "start"} className="tabular-nums">
                  <table.FlexRender cell={cell} />
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <Pagination
        aria-label="Recent workflow run pages"
        total={table.getPageCount()}
        page={pagination.pageIndex + 1}
        onPageChange={(page) => table.setPageIndex(page - 1)}
        className="self-center"
      />
    </div>
  );
}

Accessibility#

  • Every header cell is a <th scope="col">. Name the table with aria-label or aria-labelledby.
  • A sortable header holds a real <button>, and its <th> carries aria-sort: ascending, descending, or none while it is not the sorted column. The arrow beside the title is hidden from screen readers.
  • A table wider than its box becomes a region with tabIndex={0}, named by the table's own label, so the keyboard can scroll it. A table that fits stays a plain table, with no extra Tab stop.
  • A table that scrolls only up and down, in a fixed height, does not become a region. Keep something focusable in it, such as sortable headers: from there the arrows scroll it.
  • isLoading sets aria-busy="true" on the table.

Styling#

className and classNames.wrapper go to the scrolling box around the table: its border, background, corners and height. classNames.table goes to the <table> itself. Each part (TableColumn, TableRow, TableCell) takes its own className, e.g. w-10 on the checkbox column or tabular-nums on numbers.

Props#

Table#

PropTypeDefault
size
"sm""md""lg"Row height and text size.
"md"
isStriped
booleanTints every other row.
–
isHoverable
booleanHighlights the row under the pointer.
–
isHeaderSticky
booleanKeeps the header in view while the table scrolls; give the table a height through classNames.wrapper.
–
isLoading
booleanDims the rows and marks the table busy, e.g. while the next page loads.
–
classNames
Partial<Record<TableSlots, string>>
–

Also takes every attribute of <table>.

TableColumn#

PropTypeDefault
onSort
(event: React.MouseEvent<HTMLButtonElement>) => voidMakes the column sortable: the header becomes a button that calls this with the click. TanStack Table's column.getToggleSortingHandler() fits as is (Shift+click adds a sort).
–
sortDirection
false"desc""ascending""descending""asc"Current sort of this column; false or omitted when it is not the sorted one. Also takes TanStack Table's column.getIsSorted() ("asc" / "desc") as is.
–
align
"start""center""end"
"start"

Also takes every attribute of <tablecell>, except align, which mean something else here.

TableBody#

PropTypeDefault
emptyContent
React.ReactNodeShown in place of the rows when there are none, e.g. an EmptyState.
–
loadingContent
React.ReactNodeShown in place of the rows while the table is loading and has none yet.
a spinner

Also takes every attribute of <tablesection>.

TableRow#

PropTypeDefault
isSelected
booleanTints the row; pair it with a checkbox in the row for screen readers.
–

Also takes every attribute of <tablerow>.

TableCell#

PropTypeDefault
align
"start""center""end"
"start"

Also takes every attribute of <tablecell>, except align, which mean something else here.