Data Tables skill for Claude Code

Fast, accessible tables at 100k rows. Claude sizes the data before choosing an architecture, then keeps sorting, filtering and paging on the same side so users never sort one page and think it is the whole set. It stores table state in the URL, uses cursor pagination with a unique tiebreaker, virtualizes long lists, and keeps a real table with aria-sort and labeled selection that screen readers can follow.

  • namedata-tables
  • size223 lines · ≈2,500 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds large interactive data tables that stay fast and accessible — deciding client-side versus server-side sorting, filtering and pagination by row count, TanStack Table (v8 and v9) column definitions and manual modes, sort and filter state in the URL, cursor pagination, row virtualization with TanStack Virtual, sticky headers and columns, row selection across pages, and semantic table markup with aria-sort. Use when building an admin list, report grid or data browser, a table lags with thousands of rows, sorting or paging is wrong across pages, or when reviewing table accessibility.

Try asking

  • Build an orders table with server-side sorting, filters and pagination in the URL
  • Our admin table freezes with 20,000 rows — make it fast
  • Review this table component for accessibility and paging bugs

Use it when

  • Building admin lists, report grids or data browsers
  • A table lags, re-renders constantly or loses scroll with many rows
  • Rows repeat or disappear between pages after sorting
  • Adding row selection, bulk actions or column controls

Skip it when

  • You are designing a whole analytics dashboard layout — use dashboard-ui
  • The slowness is a database query behind the table — use sql-query-tuning

How to check the result

A seeded 50k-row dataset scrolls smoothly with a small DOM row count, URLs reproduce the same view in a new tab, no row appears on two pages, and axe reports no violations.

The common mistake it prevents

Sorting client-side over a single server page, so users believe the whole dataset is sorted when only fifty rows are.

~/.claude/skills/data-tables/SKILL.md

--- name: data-tables description: Builds large interactive data tables that stay fast and accessible — deciding client-side versus server-side sorting, filtering and… license: MIT ---

A good data table answers “find the row I need and act on it” quickly. It decides where sorting, filtering and paging run based on data size, keeps that state in the URL, renders only visible rows when lists are long, and remains a real <table> that screen readers and keyboards can use.

Step 0 — inspect data and constraints

Before choosing an architecture, find out:

  • Total row count now and in a year (query the API or DB, do not guess).
  • Row width: column count and whether cells hold heavy content (avatars, charts).
  • Which columns users sort and filter on, and whether the backend can do it indexed.
  • Whether users need bulk actions, inline editing, export, or column customization.
npm ls @tanstack/react-table @tanstack/table-core @tanstack/react-virtual ag-grid-react
grep -rn "useReactTable\|useTable(" src | head

Where processing runs

Rows (total)Sorting / filtering / pagingRendering
< 1,000Client; fetch all oncePlain table, client pagination optional
1,000 – 10,000Client is fine if payload < ~1–2 MB; else serverVirtualize or paginate
> 10,000 or growingServer (manual modes)Paginate or virtualize the loaded window
Spreadsheet-like editing, pivoting, 100k rows in-browserConsider AG Grid (licensing for enterprise features)Built-in virtualization

Mixed is a bug factory: never sort client-side over one server page — users think the whole dataset is sorted. If paging is on the server, sorting and filtering must be too.

TanStack Table — version check

  • v9 (stable since August 2026): useTable with a features object from tableFeatures({...}); row models registered as slots (sortedRowModel: createSortedRowModel()); state read via table.state / table.store; column helper is createColumnHelper<typeof features, Row>(); sortingFn → sortFn.
  • v8: useReactTable with getCoreRowModel(), getSortedRowModel(), etc., and table.getState().

Match the installed version; do not mix v8 and v9 APIs. Upgrading is a separate task using the official migration guide.

Server-side table (v8 shown, the most common install today)

const columnHelper = createColumnHelper<Order>();
const columns = [
  columnHelper.accessor("number", { header: "Order", enableSorting: true }),
  columnHelper.accessor("customer", { header: "Customer" }),
  columnHelper.accessor("total", {
    header: () => <span className="num">Total</span>,
    cell: (c) => <span className="num">{currency.format(c.getValue() / 100)}</span>,
  }),
  columnHelper.accessor("createdAt", { header: "Placed", cell: (c) => dateFmt.format(new Date(c.getValue())) }),
];

function OrdersTable() {
  const [search, setSearch] = useSearchParamsState();          // sort, q, status, cursor in the URL
  const sorting = parseSort(search.sort);                        // "createdAt.desc" → [{ id, desc }]
  const query = useQuery({
    queryKey: ["orders", search],
    queryFn: ({ signal }) => api.orders(search, { signal }),
    placeholderData: keepPreviousData,                           // keep rows while next page loads
  });

  const table = useReactTable({
    data: query.data?.rows ?? EMPTY,                             // stable empty array, not [] inline
    columns,
    state: { sorting },
    manualSorting: true,
    manualFiltering: true,
    manualPagination: true,
    rowCount: query.data?.total,                                 // omit with cursor pagination
    onSortingChange: (updater) => {
      const next = typeof updater === "function" ? updater(sorting) : updater;
      setSearch({ sort: formatSort(next), cursor: undefined });  // reset paging on sort change
    },
    getCoreRowModel: getCoreRowModel(),
    getRowId: (row) => row.id,                                   // stable ids for selection
  });
  // render below
}

Rules:

  • Put sort, filters, page/cursor, and page size in the URL. Shareable links, working back button, and no lost state on refresh.
  • Changing sort or filters resets the page/cursor.
  • columns and data must be referentially stable (module scope, useMemo, or query data). An inline [] or columns array re-created each render causes infinite loops or full recalculation.
  • getRowId from the domain id, never the index — selection breaks across pages otherwise.
  • Debounce text filters (250–400 ms) before writing to the URL.

Pagination choice

SituationUse
Users jump to page N, total count is cheapOffset (LIMIT/OFFSET) with page numbers
Large or fast-changing data, infinite scrollCursor/keyset (WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC)
Exact total is expensiveShow “1–50 of many” or an estimate; do not run COUNT(*) on every request

Sort keys must be deterministic: always add a unique tiebreaker (id) to ORDER BY or rows repeat and vanish between pages.

Virtualization

Virtualize when more than ~200 rows render at once.

const parentRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 40,          // fixed row height keeps it simple and fast
  overscan: 10,
});

<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
  <table style={{ display: "grid" }}>
    <thead style={{ display: "grid", position: "sticky", top: 0, zIndex: 1 }}>...</thead>
    <tbody style={{ display: "grid", height: virtualizer.getTotalSize(), position: "relative" }}>
      {virtualizer.getVirtualItems().map((vi) => {
        const row = rows[vi.index];
        return (
          <tr key={row.id} data-index={vi.index} aria-rowindex={vi.index + 2}
              style={{ display: "flex", position: "absolute", transform: `translateY(${vi.start}px)`, width: "100%" }}>
            {row.getVisibleCells().map((cell) => (
              <td key={cell.id} style={{ width: cell.column.getSize() }}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        );
      })}
    </tbody>
  </table>
</div>
  • With display: grid/flex on table parts, set explicit column widths so header and body align.
  • Add aria-rowcount on the table (total rows + header) and aria-rowindex on rows, because most rows are not in the DOM.
  • Browser find (Ctrl+F) cannot see unrendered rows; provide a search filter.
  • Combine with infinite loading: fetch the next page when the last virtual item index nears rows.length.

Accessibility

  • Use <table>, <caption> (can be visually hidden), <thead>, <th scope="col">. Use role="grid" only for spreadsheet-like cell navigation, which then requires full arrow-key handling.
  • Sortable header: a <button> inside <th>, and aria-sort="ascending|descending" on the <th> of the currently sorted column only (omit elsewhere).
  • Row selection: real checkboxes with labels (“Select order 1042”); header checkbox supports the mixed state (indeterminate).
  • Row actions: a visible menu button per row; do not hide actions on hover only.
  • Announce result changes (“43 orders match”) in a polite live region after filtering.
  • Narrow screens: horizontal scroll inside a focusable, labeled region (tabindex="0" role="region" aria-label="Orders") with the first column sticky; or switch to a card list below ~640 px when rows have few key fields.

Visual rules

  • Numbers right-aligned with font-variant-numeric: tabular-nums; text left-aligned; headers aligned with their data.
  • Row height 40–48 px (comfortable) or 32–36 px (dense mode toggle for power users).
  • Truncate long text with ellipsis and full value in a tooltip or detail view; never wrap IDs or numbers.
  • Zebra striping or row dividers, not both. Hover highlight helps tracking across wide rows.
  • Empty, loading (skeleton rows at real height), error, and no-results-for-filter are different states with different messages and actions.

Selection across pages

  • Store selected ids (a Set keyed by getRowId), not row indexes.
  • “Select all” selects the visible page; then offer “Select all 2,341 matching” which sends the filter to the server instead of ids.
  • Clear selection when filters change, or show clearly that hidden rows remain selected.

Gotchas

  • Formatting dates/currency in cells with new Intl.* per cell is slow; create formatters once at module scope.
  • Heavy cell components (menus, popovers) mounted per row kill performance: render a lightweight trigger and mount the menu on open.
  • Column resizing with columnResizeMode: "onChange" re-renders every cell on drag; use "onEnd" or CSS variables for widths on large tables.
  • Exporting “the table” must export the full filtered dataset from the server, not just the visible page.

Verification

  1. Load the largest realistic dataset (seed 50k rows): scroll stays smooth (DevTools Performance shows frames under 16 ms) and DOM row count stays near the visible count.
  2. Sort, filter, paginate; copy the URL into a new tab — same view appears. Back button restores the previous view.
  3. Page 2 sorted by a column with duplicate values: no row appears on two pages.
  4. Keyboard only: reach sort buttons, checkboxes and row actions; screen reader announces the sort state and the column header for each cell.
  5. Run axe (see accessibility-audit) on the table view: zero violations.

Report format

**Data:** ~180k orders → server sort/filter/cursor pagination, 50 per page
**State:** sort, q, status, cursor in URL; filters debounced 300 ms
**Rendering:** TanStack Table v8 (manual modes) + TanStack Virtual (40 px rows)
**A11y:** caption, aria-sort on active th, labeled row checkboxes, live region count
**Verified:** 50k-row scroll at 60 fps, URL round-trip, no duplicate rows across pages

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc