Overview
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:
- Displaying structured, comparable data (users, orders, metrics, logs)
- When users need to scan across rows and compare column values
- When data needs sorting, filtering, or inline actions
- For data with 3+ attributes per item
When NOT to use a Table:
- For 1–2 attributes per item — use a List instead
- For non-comparable data — use Cards for visually rich, independent items
- On mobile for wide data sets — tables don't collapse gracefully. Consider a List or stacked card pattern.
- For layout purposes — never use
<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.
Variants
Table Variants
| 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 Variants
| 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.
Properties
Table Properties
| 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 Mappings
Design Token Mappings
| 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.
States
Table States
| 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 |
Accessibility
WCAG Requirements
| 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 |
ARIA Attributes for Tables
<table role="grid">— Usegridrole 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 (withrole="row").aria-rowcountandaria-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.
Keyboard Interaction
| 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 |
Responsive Accessibility
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.
Usage Guidelines
Do's
- ✅ Right-align numeric data. Numbers are compared by digit position — right-alignment makes columns scannable.
- ✅ Left-align text data. Standard reading direction for strings and labels.
- ✅ Provide a clear empty state when filters return zero results. Include a "Clear filters" action.
- ✅ Use monospace or tabular-lining fonts for numbers. This ensures digits align vertically across rows (
font-variant-numeric: tabular-nums). - ✅ Truncate long text with ellipsis and provide a tooltip or expand-on-click for the full value.
- ✅ Show row count ("Showing 1–25 of 342") so users know the data scope.
Don'ts
- ❌ Don't make every column sortable. Only enable sorting on columns where it's useful (date, name, amount). Sorting by "Actions" is meaningless.
- ❌ Don't use horizontal scroll as a first resort. First, reduce column count. Second, make columns collapsible. Horizontal scroll is a last resort.
- ❌ Don't put complex components in table cells (dropdowns, date pickers). Use row-level actions (an action menu or row expansion) instead.
- ❌ Don't zebra-stripe AND add borders. Pick one visual separator, not both. Zebra stripes alone are usually sufficient.
- ❌ Don't paginate with fewer than 10 rows per page. 25–50 rows is a good default. Let users choose.
Responsive Strategy
- Priority columns: Identify 2–3 must-see columns. Always show these.
- Collapsible columns: Hide low-priority columns behind a "Columns" toggle.
- Stacked layout: Below 640px, transform rows into stacked cards.
- Horizontal scroll: Only for data grids where all columns are essential (financial data, spreadsheets).
Code Snippets
<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>
);
}Design Systems
Cross-System Comparison
| 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 |
Notable Approaches
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>.