Skip to content

Table

Displays structured data in rows and columns with optional sorting, filtering, and pagination.

  • Data Table
  • Data Grid

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

VariantDescriptionBest For
BasicSimple rows and columns, no interactivityRead-only data, static reports
SortableColumn headers are clickable to sort ascending/descendingAny dataset where order matters
FilterableFilter controls (search, dropdowns) above or within columnsLarge datasets (50+ rows)
SelectableCheckboxes in the first column for row selectionBulk actions (delete, export, assign)
ExpandableRows expand to reveal nested detail contentMaster-detail patterns, order line items
EditableCells are inline-editable on click or double-clickSpreadsheet-like interfaces, quick updates
VirtualizedOnly renders visible rows, scrolls smoothly through thousands1,000+ rows. Use react-virtual or similar.
Sticky HeaderHeader row stays fixed while the body scrollsAny table taller than the viewport

Density Variants

DensityRow HeightUse Case
Comfortable52pxDefault. Good readability, touch-friendly.
Compact40pxData-dense admin panels. Power users.
Spacious64pxMarketing dashboards, presentation contexts.

Use our Spacing Calculator to define consistent row padding across density variants.

Properties

Table Properties

PropertyTypeDefaultDescription
columnsColumnDef[]—Column definitions: key, header label, width, sortable, render function
dataT[][]Array of row objects
sortablebooleanfalseEnables column sorting
selectablebooleanfalseAdds 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
loadingbooleanfalseShows skeleton rows while data loads
emptyStateReactNode—Content shown when data is empty. See Empty State.
stickyHeaderbooleanfalseFixes the header row during vertical scroll
density'compact' | 'comfortable' | 'spacious''comfortable'Controls row height and padding
stripedbooleanfalseAlternating row background colors for readability

Token Mappings

Design Token Mappings

Token CategoryToken ExampleTable Usage
Color – Header--color-surface-secondaryHeader row background
Color – Row--color-surfaceDefault row background
Color – Row Alt--color-surface-subtleStriped row alternate background
Color – Row Hover--color-surface-hoverRow hover highlight
Color – Row Selected--color-primary-50Selected row background
Color – Border--color-border-subtleCell borders, divider lines
Spacing--space-3 (12px), --space-4 (16px)Cell padding. Use Spacing Calculator.
Typography--font-size-sm, --font-weight-mediumHeader text vs body text
Border Radius--radius-lg (12px)Table container corners
Shadow--shadow-smTable container elevation

Read our Design Tokens Complete Guide for token naming conventions.

States

Table States

StateVisual BehaviorNotes
DefaultRows displayed with data, headers visibleBase state
LoadingSkeleton rows or spinner overlayShow Skeleton rows matching the expected layout. Never show an empty table with a spinner — it's disorienting.
EmptyEmpty State component centered in the table bodyShow a helpful message + action: "No results found. Try adjusting your filters."
ErrorError message in table body with retry actionNetwork errors, permission issues
Row HoverBackground color change on the hovered rowUse subtle color shift — not bold highlight
Row SelectedCheckbox checked, row background tinted with primary colorShow bulk action toolbar when ≥1 rows selected
SortingActive sort column header shows directional arrow (↑↓)Only one column sorted at a time (unless supporting multi-sort)
Column ResizingDrag handle visible on column borders during hoverCursor changes to col-resize

Accessibility

WCAG Requirements

CriterionLevelRequirement
SC 1.3.1 Info and RelationshipsAUse semantic <table>, <thead>, <tbody>, <th>, <td> elements. Add <caption> for table title.
SC 1.3.2 Meaningful SequenceAReading order must make sense. Don't reorder columns visually without matching the DOM.
SC 2.1.1 KeyboardAAll interactive elements (sort buttons, checkboxes, action menus) must be keyboard operable
SC 1.4.3 Contrast (Minimum)AATable text, borders, and interactive controls must meet contrast requirements

ARIA Attributes for Tables

  • <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.

Keyboard Interaction

KeyAction
TabMoves between interactive elements within cells (links, buttons, checkboxes)
Arrow keysNavigate between cells (when using role="grid")
SpaceToggle row selection checkbox
EnterActivate 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

  1. Priority columns: Identify 2–3 must-see columns. Always show these.
  2. Collapsible columns: Hide low-priority columns behind a "Columns" toggle.
  3. Stacked layout: Below 640px, transform rows into stacked cards.
  4. Horizontal scroll: Only for data grids where all columns are essential (financial data, spreadsheets).

Code Snippets

html
<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>
tsx
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

FeatureMaterial 3Shadcn/uiRadixAnt Design
ComponentDataTable (MUI X)Table (styled <table>)No table primitiveTable, ProTable
SortingBuilt-inManual via TanStack TableN/ABuilt-in sorter prop
FilteringBuilt-in (MUI X Pro)ManualN/ABuilt-in filters prop
SelectionCheckbox columnManualN/ArowSelection config
PaginationBuilt-inSeparate Pagination componentN/ABuilt-in
VirtualizationMUI X ProManual (TanStack Virtual)N/Avirtual prop (v5+)
Column resizeMUI X Pro ($)ManualN/AProTable
Editable cellsMUI X Pro ($)ManualN/AProTable
Server-sideSupportedManual integrationN/ABuilt-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>.

Data DisplayData TableData Grid