Build a React Table Component with Claude Code: Sort, Filter, Pagination
Build a React table with Claude Code: sorting, filtering, pagination, mobile CSS, TanStack Table, and Playwright.
Start with the table contract, not the pixels
Tables show up everywhere: customer lists, billing history, admin dashboards, product catalogs, article analytics, CSV previews, and internal reports. The first version often starts as “render these rows,” but the real work arrives quickly. Someone asks for sorting by revenue, filtering by status, pagination for long lists, mobile layout, keyboard operation, and a quick way to prove the component still works after a design change.
Claude Code can move this work fast, but only if the prompt describes the table as a product surface, not just a block of markup. A weak prompt such as “make a nice table” can produce a visual grid that ignores semantic HTML, loses the current page after filtering, or shows a sort arrow without telling assistive technology which column is sorted. A strong prompt names the data type, table semantics, state transitions, responsive behavior, accessibility rules, and verification commands.
This guide builds a copy-pasteable React and TypeScript table component with sortable columns, global filtering, pagination, responsive/mobile handling, accessibility details, a TanStack Table upgrade path, and Playwright checks. For the broader React workflow, pair it with React development with Claude Code. For deeper accessibility review, see accessibility with Claude Code. For E2E coverage, use it with Playwright testing with Claude Code.
Use official sources when checking details. The baseline table semantics are in MDN’s <table> reference. Sort announcements are covered by MDN aria-sort. More complex table state is documented in TanStack Table. Interaction tests are covered in Playwright Writing tests. Claude Code workflow details start from the Claude Code overview.
Semantic table basics
Use a table when rows and columns create meaning together. A customer row with customer name, plan, monthly recurring revenue, status, and signup date is tabular data because every value is understood through its column header. If the layout is just a set of independent cards, a list or card grid may be better. If the user needs to compare values across columns, keep the native table.
The minimum structure is table, caption, thead, tbody, and th scope="col". The caption tells the purpose of the table. Column headers use th with scope="col". If the first cell in each row identifies the row, use th scope="row" for that cell. This gives browsers and assistive technology a clear relationship between the row label and the data cells.
<table>
<caption>Monthly recurring revenue by customer</caption>
<thead>
<tr>
<th scope="col">Customer</th>
<th scope="col">Plan</th>
<th scope="col">MRR</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Northwind</th>
<td>Pro</td>
<td>$1,200</td>
</tr>
</tbody>
</table>
This is the part beginners often skip because the visual result can look identical with divs. The difference appears when someone navigates with a screen reader, tests keyboard operation, exports the data, or asks why sorting is confusing. Claude Code should be told to preserve the semantic table structure from the first prompt.
flowchart TD
A["Requirements"] --> B["Semantic table"]
B --> C["Sort, filter, pagination state"]
C --> D["Mobile behavior"]
D --> E["Accessibility review"]
E --> F["Playwright checks"]
Prompt Claude Code with constraints
The best prompt names what can change and what must not change. For a production app, I usually include the target files, data shape, allowed dependencies, accessibility rules, and verification commands. That prevents Claude Code from solving a small table problem by adding an entire UI kit or replacing an existing design system.
Build a React + TypeScript customer table component.
Scope:
- Only add or update src/components/DataTable.tsx and src/components/data-table.css
- Use native table, caption, thead, tbody, and th scope attributes
- Customer fields: id, name, plan, mrr, status, signedUpAt
- Add global filtering, column sorting, and 5-row pagination
- Put aria-sort only on the currently sorted column
- Use buttons inside column headers for sorting
- On mobile, expose cell labels with data-label instead of relying only on horizontal scroll
- Add a Playwright test for filtering, sorting, pagination, and mobile labels
Do not:
- Add a new UI library
- Use role="grid" unless you also implement the required grid keyboard model
- Return pseudocode
The last line matters. A table looks simple enough that pseudo-implementations sneak in easily: comments where pagination should be, sort handlers that only toggle an icon, or tests that assert the page loaded but never sort or filter. Ask for runnable code and concrete checks.
Copy-paste React and TypeScript implementation
The following component is intentionally dependency-light. It works in a client-side React app and in a Next.js client component. If you are not using Next.js, the "use client"; directive is harmless, though you can remove it.
// src/components/DataTable.tsx
"use client";
import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";
type SortDirection = "asc" | "desc";
type SortState<T> = {
key: keyof T;
direction: SortDirection;
} | null;
export type Customer = {
id: string;
name: string;
plan: "Free" | "Pro" | "Enterprise";
mrr: number;
status: "active" | "trial" | "paused";
signedUpAt: string;
};
type Column<T> = {
key: keyof T;
label: string;
numeric?: boolean;
render?: (value: T[keyof T], row: T) => ReactNode;
};
const pageSize = 5;
const sampleCustomers: Customer[] = [
{ id: "cus_001", name: "Northwind", plan: "Pro", mrr: 1200, status: "active", signedUpAt: "2026-01-15" },
{ id: "cus_002", name: "Blue Bottle", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-02-02" },
{ id: "cus_003", name: "Kobayashi Studio", plan: "Enterprise", mrr: 8400, status: "active", signedUpAt: "2025-11-20" },
{ id: "cus_004", name: "Atlas Foods", plan: "Pro", mrr: 980, status: "paused", signedUpAt: "2025-12-09" },
{ id: "cus_005", name: "Green Lab", plan: "Pro", mrr: 1600, status: "active", signedUpAt: "2026-03-01" },
{ id: "cus_006", name: "Sakura Dental", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-03-18" },
];
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const columns: Column<Customer>[] = [
{ key: "name", label: "Customer" },
{ key: "plan", label: "Plan" },
{
key: "mrr",
label: "MRR",
numeric: true,
render: (_, row) => currency.format(row.mrr),
},
{
key: "status",
label: "Status",
render: (_, row) => <span className={`status status-${row.status}`}>{row.status}</span>,
},
{
key: "signedUpAt",
label: "Signed up",
render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US"),
},
];
function compareValues<T>(leftRow: T, rightRow: T, key: keyof T) {
const left = leftRow[key];
const right = rightRow[key];
if (typeof left === "number" && typeof right === "number") {
return left - right;
}
return String(left).localeCompare(String(right), undefined, {
numeric: true,
sensitivity: "base",
});
}
export function DataTable({ rows = sampleCustomers }: { rows?: Customer[] }) {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sort, setSort] = useState<SortState<Customer>>({
key: "name",
direction: "asc",
});
const filteredRows = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
columns.some((column) =>
String(row[column.key]).toLowerCase().includes(keyword),
),
);
}, [query, rows]);
const sortedRows = useMemo(() => {
if (!sort) return filteredRows;
return [...filteredRows].sort((left, right) => {
const result = compareValues(left, right, sort.key);
return sort.direction === "asc" ? result : -result;
});
}, [filteredRows, sort]);
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return sortedRows.slice(start, start + pageSize);
}, [currentPage, sortedRows]);
function updateQuery(value: string) {
setQuery(value);
setPage(1);
}
function toggleSort(key: keyof Customer) {
setSort((current) => {
if (!current || current.key !== key) {
return { key, direction: "asc" };
}
return {
key,
direction: current.direction === "asc" ? "desc" : "asc",
};
});
}
return (
<section className="table-shell" aria-labelledby="customers-table-title">
<div className="table-actions">
<h2 id="customers-table-title">Customers</h2>
<label>
<span>Filter customers</span>
<input
type="search"
value={query}
onChange={(event) => updateQuery(event.target.value)}
placeholder="Search name, plan, or status"
/>
</label>
</div>
<div className="table-scroll" tabIndex={0}>
<table className="data-table">
<caption>Monthly recurring revenue by customer</caption>
<thead>
<tr>
{columns.map((column) => {
const isSorted = sort?.key === column.key;
const ariaSort = isSorted
? sort.direction === "asc"
? "ascending"
: "descending"
: undefined;
return (
<th
key={String(column.key)}
scope="col"
aria-sort={ariaSort}
className={column.numeric ? "numeric" : undefined}
>
<button type="button" onClick={() => toggleSort(column.key)}>
{column.label}
<span aria-hidden="true">
{isSorted ? (sort.direction === "asc" ? " ▲" : " ▼") : ""}
</span>
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{pageRows.length > 0 ? (
pageRows.map((row) => (
<tr key={row.id}>
{columns.map((column, index) => {
const content = column.render
? column.render(row[column.key], row)
: String(row[column.key]);
if (index === 0) {
return (
<th key={String(column.key)} scope="row" data-label={column.label}>
{content}
</th>
);
}
return (
<td
key={String(column.key)}
data-label={column.label}
className={column.numeric ? "numeric" : undefined}
>
{content}
</td>
);
})}
</tr>
))
) : (
<tr>
<td colSpan={columns.length}>No customers match this filter.</td>
</tr>
)}
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Table pagination">
<button type="button" onClick={() => setPage((value) => value - 1)} disabled={currentPage === 1}>
Previous
</button>
<span aria-live="polite">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
onClick={() => setPage((value) => value + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
</nav>
</section>
);
}
The important behavior is not the arrow icon. The important behavior is that sorting changes the actual row order, the sorted header receives aria-sort, filtering resets the page to 1, and pagination never shows a blank page when matching rows exist.
Responsive CSS for mobile tables
There is no single correct mobile table pattern. If the user must compare many columns, horizontal scrolling can be honest and useful. If the table is an operational list where each row can stand on its own, a stacked mobile layout is often easier. The CSS below keeps the semantic table in the DOM and only changes the visual presentation under 640px.
/* src/components/data-table.css */
.table-shell {
display: grid;
gap: 1rem;
}
.table-actions {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.table-actions label {
display: grid;
gap: 0.35rem;
min-width: min(100%, 18rem);
}
.table-actions input {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 0.55rem 0.75rem;
}
.table-scroll {
overflow-x: auto;
border: 1px solid #d8dee8;
border-radius: 0.5rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.data-table caption {
padding: 0.75rem;
text-align: left;
font-weight: 600;
}
.data-table th,
.data-table td {
border-top: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
vertical-align: middle;
}
.data-table th.numeric,
.data-table td.numeric {
text-align: right;
}
.data-table th button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0;
}
.data-table tbody tr:hover {
background: #f8fafc;
}
.status {
border-radius: 999px;
display: inline-block;
font-size: 0.8rem;
padding: 0.2rem 0.55rem;
}
.status-active {
background: #dcfce7;
color: #166534;
}
.status-trial {
background: #e0f2fe;
color: #075985;
}
.status-paused {
background: #fef3c7;
color: #92400e;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.pagination button {
border: 1px solid #cbd5e1;
border-radius: 0.45rem;
background: white;
padding: 0.45rem 0.75rem;
}
.pagination button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
@media (max-width: 640px) {
.table-scroll {
overflow: visible;
border: 0;
}
.data-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.data-table,
.data-table tbody,
.data-table tr,
.data-table th,
.data-table td {
display: block;
width: 100%;
}
.data-table tr {
border: 1px solid #d8dee8;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
padding: 0.25rem 0;
}
.data-table th,
.data-table td,
.data-table th.numeric,
.data-table td.numeric {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: 0.75rem;
text-align: left;
}
.data-table th::before,
.data-table td::before {
color: #64748b;
content: attr(data-label);
font-weight: 700;
}
}
Notice that the component sets data-label on every body cell. That is not just a styling trick. It keeps the mobile layout predictable because the CSS never has to guess which column a cell belongs to.
Accessibility review points
For this kind of table, avoid jumping straight to ARIA-heavy patterns. Native tables already carry useful semantics. Add ARIA only where state needs to be communicated, such as the current sort direction. The sort action should be a real button inside the header cell, not a clickable header with no keyboard affordance.
Review the component with this checklist:
- Does the caption describe what the rows represent?
- Are column headers th elements with scope="col"?
- Is the row label a th with scope="row" when appropriate?
- Is aria-sort present only on the sorted column?
- Can the user sort with keyboard focus on a button?
- Does the filter input have an accessible label?
- Does pagination announce the current page with aria-live?
- Is role="grid" avoided unless grid keyboard behavior is implemented?
The role="grid" warning is practical. A grid implies a richer interaction model. If the component is only a sortable and filterable data table, the native table is easier to implement correctly and easier for users to understand.
When to choose TanStack Table
The dependency-free component is enough for many admin lists. Move to TanStack Table when state grows beyond simple sorting and filtering: column visibility, per-column filters, row selection, pinned columns, server-side pagination, grouping, or virtualization. TanStack Table is headless, so it manages table logic while you keep control over markup and styles.
| Option | Best fit | Tradeoff |
|---|---|---|
| Custom component | Small list, a few columns, simple sort/filter | You own every new feature |
| TanStack Table | Complex columns, server state, selection, pagination | You must learn the API and build the UI |
| Enterprise grid | Spreadsheet-like editing and very large data | Higher setup, bundle, and licensing concerns |
import {
type ColumnDef,
type PaginationState,
type SortingState,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
const tanStackColumns: ColumnDef<Customer>[] = [
{ accessorKey: "name", header: "Customer" },
{ accessorKey: "plan", header: "Plan" },
{ accessorKey: "mrr", header: "MRR" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "signedUpAt", header: "Signed up" },
];
export function useCustomerTable(data: Customer[]) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
return useReactTable({
data,
columns: tanStackColumns,
state: { sorting, globalFilter, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
}
Ask Claude Code to justify the dependency before adding it. A table used on one settings page does not need the same architecture as a CRM-style grid that will keep gaining features.
Playwright checks
The table should be tested as a workflow, not as static markup. Check that a user can see the table, sort a column, filter rows, move to the next page, and still understand cells at a mobile viewport. Playwright’s locator style works well here because the test can target roles and labels instead of fragile CSS selectors.
// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";
test("customer table filters, sorts, paginates, and keeps mobile labels", async ({ page }) => {
await page.goto("/customers");
await expect(
page.getByRole("table", { name: /monthly recurring revenue/i }),
).toBeVisible();
await page.getByRole("button", { name: /MRR/ }).click();
await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute(
"aria-sort",
"ascending",
);
await page.getByLabel("Filter customers").fill("north");
await expect(page.getByRole("row", { name: /Northwind/ })).toBeVisible();
await expect(page.getByText("No customers match this filter.")).toBeHidden();
await page.getByLabel("Filter customers").fill("");
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Page 2 of 2")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});
This catches the failures I see most often: a sort button that does not update state, filtering that leaves the user on an empty page, and mobile CSS that hides the column context.
Practical use cases and pitfalls
| Use case | Table features | Business value |
|---|---|---|
| SaaS customer list | Plan, MRR, status, renewal date | Find churn risk and upsell candidates |
| Ecommerce catalog | Stock, price, publish state, category | Catch out-of-stock and pricing issues faster |
| Content dashboard | Traffic, read rate, CTA clicks, last update | Prioritize rewrites and monetization work |
| Billing history | Payment state, amount, due date, customer | Reduce support time on invoices |
The common pitfall is treating the table as a visual component only. It is actually a decision surface. If the row should trigger an action, design that action. If revenue depends on the table, track the click or update. For article operations, combine this with analytics implementation with Claude Code so CTA clicks and rewrite priority are visible beside the content list.
Other pitfalls are mechanical: using divs for tabular data, putting aria-sort on every column, forgetting to reset pagination after filtering, relying only on horizontal scrolling on narrow screens, or letting Claude Code add a UI library that the project does not already use. Put these constraints in the prompt and ask for a final accessibility review.
Monetization note and tested result
Tables support monetization when they make the next action obvious. In a media site, a content table with page views, read completion, CTA clicks, and revenue per article can guide rewrites. In a SaaS product, a customer table with MRR, usage drop, and renewal date can guide customer success outreach. The table itself does not make money, but it makes the work that improves revenue visible.
If your team wants a repeatable Claude Code workflow for dashboards and internal tools, see Claude Code training and consulting. If you are learning solo, the products and templates page collects prompts and implementation checklists.
Masa tested this structure on a small customer list. The biggest practical fix was resetting pagination when the filter changed; without it, filtered results could exist while page 2 looked empty. The second useful fix was adding mobile data-label values from the beginning instead of patching mobile CSS later. Asking Claude Code for semantics, state, mobile behavior, accessibility, and Playwright checks in one task produced a table that was easier to review and harder to break.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.