Loading…
Loading…
Displays structured data in rows and columns with optional sorting, filtering, and pagination.
The Table (or Data Table) displays structured data in a grid of rows and columns. It's one of the most complex UI components to get right — handling sorting, filtering, pagination, selection, responsive behavior, and accessibility simultaneously.
Tables are the backbone of enterprise applications: admin panels, dashboards, analytics views, CRM systems. A well-built table lets users scan, compare, and act on data efficiently. A poorly built one becomes an unusable wall of information.
When to use a Table:
When NOT to use a Table:
<table> for page layout. That era ended in 2005.Decision tree: If the user needs to compare values across rows → Table. If the user browses items independently → Card or List.
| Variant | Description | Best For |
|---|---|---|
| Basic | Simple rows and columns, no interactivity | Read-only data, static reports |
| Sortable | Column headers are clickable to sort ascending/descending | Any dataset where order matters |
| Filterable | Filter controls (search, dropdowns) above or within columns | Large datasets (50+ rows) |
| Selectable | Checkboxes in the first column for row selection | Bulk actions (delete, export, assign) |
| Expandable | Rows expand to reveal nested detail content | Master-detail patterns, order line items |
| Editable | Cells are inline-editable on click or double-click | Spreadsheet-like interfaces, quick updates |
| Virtualized | Only renders visible rows, scrolls smoothly through thousands | 1,000+ rows. Use react-virtual or similar. |
| Sticky Header | Header row stays fixed while the body scrolls | Any table taller than the viewport |
| Density | Row Height | Use Case |
|---|---|---|
| Comfortable | 52px | Default. Good readability, touch-friendly. |
| Compact | 40px | Data-dense admin panels. Power users. |
| Spacious | 64px | Marketing dashboards, presentation contexts. |
Use our Spacing Calculator to define consistent row padding across density variants.
| Property | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef[] | — | Column definitions: key, header label, width, sortable, render function |
data | T[] | [] | Array of row objects |
sortable | boolean | false | Enables column sorting |
selectable | boolean | false | Adds checkbox column for row selection |
onSelectionChange | (selectedIds: string[]) => void | — | Callback when selected rows change |
pagination | { page: number; pageSize: number; total: number } | — | Pagination configuration |
onSort | (column: string, direction: 'asc' | 'desc') => void | — | Callback when sort changes |
loading | boolean | false | Shows skeleton rows while data loads |
emptyState | ReactNode | — | Content shown when data is empty. See Empty State. |
stickyHeader | boolean | false | Fixes the header row during vertical scroll |
density | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Controls row height and padding |
striped | boolean | false | Alternating row background colors for readability |
| Token Category | Token Example | Table Usage |
|---|---|---|
| Color – Header | --color-surface-secondary | Header row background |
| Color – Row | --color-surface | Default row background |
| Color – Row Alt | --color-surface-subtle | Striped row alternate background |
| Color – Row Hover | --color-surface-hover | Row hover highlight |
| Color – Row Selected | --color-primary-50 | Selected row background |
| Color – Border | --color-border-subtle | Cell borders, divider lines |
| Spacing | --space-3 (12px), --space-4 (16px) | Cell padding. Use Spacing Calculator. |
| Typography | --font-size-sm, --font-weight-medium | Header text vs body text |
| Border Radius | --radius-lg (12px) | Table container corners |
| Shadow | --shadow-sm | Table container elevation |
Read our Design Tokens Complete Guide for token naming conventions.
| State | Visual Behavior | Notes |
|---|---|---|
| Default | Rows displayed with data, headers visible | Base state |
| Loading | Skeleton rows or spinner overlay | Show Skeleton rows matching the expected layout. Never show an empty table with a spinner — it's disorienting. |
| Empty | Empty State component centered in the table body | Show a helpful message + action: "No results found. Try adjusting your filters." |
| Error | Error message in table body with retry action | Network errors, permission issues |
| Row Hover | Background color change on the hovered row | Use subtle color shift — not bold highlight |
| Row Selected | Checkbox checked, row background tinted with primary color | Show bulk action toolbar when ≥1 rows selected |
| Sorting | Active sort column header shows directional arrow (↑↓) | Only one column sorted at a time (unless supporting multi-sort) |
| Column Resizing | Drag handle visible on column borders during hover | Cursor changes to col-resize |
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.3.1 Info and Relationships | A | Use semantic <table>, <thead>, <tbody>, <th>, <td> elements. Add <caption> for table title. |
| SC 1.3.2 Meaningful Sequence | A | Reading order must make sense. Don't reorder columns visually without matching the DOM. |
| SC 2.1.1 Keyboard | A | All interactive elements (sort buttons, checkboxes, action menus) must be keyboard operable |
| SC 1.4.3 Contrast (Minimum) | AA | Table text, borders, and interactive controls must meet contrast requirements |
<table role="grid"> — Use grid role when cells are interactive (editable, navigable). Use default table semantics for read-only data.aria-sort="ascending|descending|none" — On sortable <th> elements. This tells screen readers the current sort state.aria-selected="true" — On selected rows (with role="row").aria-rowcount and aria-rowindex — For virtualized tables where only a subset of rows are rendered.<caption> — Always provide a table caption, even if visually hidden. It announces "Table: User accounts" to screen readers.| Key | Action |
|---|---|
| Tab | Moves between interactive elements within cells (links, buttons, checkboxes) |
| Arrow keys | Navigate between cells (when using role="grid") |
| Space | Toggle row selection checkbox |
| Enter | Activate sort on focused column header, or trigger cell action |
When tables collapse to stacked layouts on mobile, maintain data relationships by associating each value with its column header using data-label attributes or visually-hidden labels. Never just hide columns without consideration — hidden data might be critical. Check your text contrast across all viewport sizes with our Contrast Checker.
For more on accessible data display, read our WCAG Practical Guide.
font-variant-numeric: tabular-nums).<div class="table-container" role="region" aria-label="User accounts" tabindex="0">
<table>
<caption class="sr-only">User accounts — sortable by name, email, and role</caption>
<thead>
<tr>
<th scope="col">
<input type="checkbox" aria-label="Select all rows" />
</th>
<th scope="col" aria-sort="ascending">
<button type="button" class="sort-btn">
Name <span aria-hidden="true">↑</span>
</button>
</th>
<th scope="col" aria-sort="none">
<button type="button" class="sort-btn">Email</button>
</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" aria-label="Select Jane Cooper" /></td>
<td>Jane Cooper</td>
<td>jane@example.com</td>
<td>Admin</td>
<td><span class="badge badge-success">Active</span></td>
<td>
<button type="button" aria-label="Actions for Jane Cooper" class="btn btn-ghost btn-icon">
⋯
</button>
</td>
</tr>
</tbody>
</table>
</div>import { useState, useMemo, type ReactNode } from "react";
interface Column<T> {
key: keyof T & string;
header: string;
sortable?: boolean;
align?: "left" | "right" | "center";
render?: (value: T[keyof T], row: T) => ReactNode;
}
interface TableProps<T extends { id: string }> {
columns: Column<T>[];
data: T[];
selectable?: boolean;
striped?: boolean;
caption?: string;
}
export default function Table<T extends { id: string }>({
columns,
data,
selectable = false,
striped = false,
caption,
}: TableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [selected, setSelected] = useState<Set<string>>(new Set());
const sorted = useMemo(() => {
if (!sortKey) return data;
return [...data].sort((a, b) => {
const av = a[sortKey as keyof T], bv = b[sortKey as keyof T];
const cmp = String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
const toggleSort = (key: string) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
};
return (
<div className="table-container" role="region" aria-label={caption} tabIndex={0}>
<table>
{caption && <caption className="sr-only">{caption}</caption>}
<thead>
<tr>
{selectable && (
<th scope="col">
<input
type="checkbox"
aria-label="Select all"
checked={selected.size === data.length && data.length > 0}
onChange={(e) =>
setSelected(e.target.checked ? new Set(data.map((r) => r.id)) : new Set())
}
/>
</th>
)}
{columns.map((col) => (
<th
key={col.key}
scope="col"
aria-sort={sortKey === col.key ? sortDir === "asc" ? "ascending" : "descending" : undefined}
style={{ textAlign: col.align ?? "left" }}
>
{col.sortable ? (
<button type="button" onClick={() => toggleSort(col.key)}>
{col.header} {sortKey === col.key && (sortDir === "asc" ? "↑" : "↓")}
</button>
) : col.header}
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((row, i) => (
<tr key={row.id} className={striped && i % 2 ? "bg-subtle" : ""}>
{selectable && (
<td>
<input
type="checkbox"
aria-label={`Select row ${row.id}`}
checked={selected.has(row.id)}
onChange={() => {
const next = new Set(selected);
next.has(row.id) ? next.delete(row.id) : next.add(row.id);
setSelected(next);
}}
/>
</td>
)}
{columns.map((col) => (
<td key={col.key} style={{ textAlign: col.align ?? "left" }}>
{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Component | DataTable (MUI X) | Table (styled <table>) | No table primitive | Table, ProTable |
| Sorting | Built-in | Manual via TanStack Table | N/A | Built-in sorter prop |
| Filtering | Built-in (MUI X Pro) | Manual | N/A | Built-in filters prop |
| Selection | Checkbox column | Manual | N/A | rowSelection config |
| Pagination | Built-in | Separate Pagination component | N/A | Built-in |
| Virtualization | MUI X Pro | Manual (TanStack Virtual) | N/A | virtual prop (v5+) |
| Column resize | MUI X Pro ($) | Manual | N/A | ProTable |
| Editable cells | MUI X Pro ($) | Manual | N/A | ProTable |
| Server-side | Supported | Manual integration | N/A | Built-in with onChange |
TanStack Table (formerly React Table) deserves a special mention. It's not a design system component but a headless table engine. Shadcn/ui officially recommends it, and many custom design systems use it under the hood. It handles sorting, filtering, pagination, grouping, and virtualization — you provide the UI.
MUI X DataGrid is the most feature-complete table implementation in the React ecosystem. The free tier covers sorting, filtering, and pagination. The Pro tier ($) adds column groups, tree data, Excel export, and virtualization. If you're building enterprise software, it's worth evaluating.
Ant Design's ProTable wraps their standard Table with search forms, toolbar actions, and column configuration — essentially a full CRUD interface in one component. It's opinionated but saves enormous development time.
Radix intentionally omits a table primitive. Tables are semantic HTML — there's no behavior to abstract. Focus your custom table efforts on the interactive layer (sorting, filtering) rather than reinventing <table>.