Tables & Data Grids
Master the most data-dense UI pattern in the toolkit — designing tables and grids that are scannable, accessible, and fast to act on at any scale.
10 min read
| Ada Lovelace | Designer | Active | 2d ago |
| Alan Turing | Engineer | Active | 9d ago |
| Grace Hopper | PM | Invited | 1d ago |
| Katherine Johnson | Researcher | Inactive | 21d ago |
| Linus Torvalds | Engineer | Active | 5d ago |
Sortable headers expose aria-sort, right-align numeric columns, and use a subtle row hover for scannability.
The full lesson
Tables are the backbone of data-heavy products — admin dashboards, analytics tools, CRMs, and financial platforms. A well-designed table lets users compare, manage, and act on records quickly. A poorly designed one turns into a wall of text that sends users straight to a spreadsheet. This lesson covers the structural, visual, and interaction decisions that separate great data grids from the ones that generate support tickets.
Anatomy of a Well-Designed Table
Every table shares the same structural pieces. The quality of each piece determines whether the whole thing works.
Column headers label the data below and let users sort. Keep headers short — 1 to 3 words — and unambiguous. “Created” is better than “Date of Record Creation.” Context makes the meaning obvious.
Rows represent individual records. Row height should fit the tallest cell without extra padding. A common mistake is using fixed-height rows that clip long values. This hides data and forces hover-to-reveal patterns that break on mobile and for keyboard users.
Cells carry the actual data. Alignment matters: left-align text, right-align numbers. Right-aligning numbers lets users scan and compare values by magnitude — a convention from accounting ledgers that works because of how we read digit columns. Centering everything looks tidy but actively harms comparison.
Action columns (inline edit, delete, view) should be pinned to the right. Use icon buttons with accessible labels. Never rely on icons alone — what looks like an obvious “delete” icon to one designer is cryptic to a new user. Add a tooltip or visible label.
The table footer can hold pagination controls, row counts, or totals. Keep it persistent. Users frequently need to orient themselves (“showing 1–25 of 847 records”) before deciding whether to filter.
Sorting, Filtering, and Search
Users almost never want to stare at the full, unsorted dataset. The value of a table comes from being able to interrogate it.
Sorting
Sort controls belong in the column header. One click sorts ascending. A second click sorts descending. A third click (or a clear action) resets to the default order. Show the current sort with a directional arrow on the active column — and only that column. Showing arrows on every header creates visual noise and hides which column is actually sorted.
For multi-column sort — for example, sort by status, then by date within each status — expose this through a dedicated sort panel. Most users never need multi-sort. Building it into sequential header clicks creates a confusing interaction with no visible affordance.
Filtering
Column-level filters work well for simple attribute filtering (status = “active”). Global search bars work better when users need to find a specific record by name or ID. The most powerful grids combine both: a search bar for record lookup, plus column filter chips for slicing by attribute.
Filter state must be visible and reversible. Show a persistent filter bar or chip row above the table that lists active filters and lets users remove them one at a time. Never hide active filters in a collapsed panel. When filters are hidden, users lose track of what they are seeing and cannot tell why results look wrong.
Column Management
For power-user grids, let users show, hide, and reorder columns. Store their preferences in localStorage or user settings so changes persist across sessions. A “Columns” button with a popover checklist is the standard pattern. Avoid drag-to-reorder column headers unless you can also support it with a keyboard fallback.
Loading States and Empty States
A table that jumps from “fetching” to “loaded” without care creates jarring flickers, layout shifts, and confused users.
Use skeleton screens for initial table loads when the row count is known or can be estimated. Skeleton rows should match the approximate height and column layout of real data. For pagination or filter changes, use a subtle loading overlay on the existing table body — dim the rows and show a spinner. This preserves layout stability better than unmounting the table and showing a full skeleton on every change.
Empty states are not an afterthought. They are the first-run experience for new accounts, a recovery message when filters over-restrict, and an onboarding moment when data comes from user action.
Match the empty state message to the cause:
| Cause | Message pattern |
|---|---|
| No data created yet | ”You have no projects yet. Create your first one to get started.” + primary action button |
| Filters returned no results | ”No results match your current filters.” + “Clear filters” link |
| Search returned no results | ”No results for ‘search term’” + suggestions or fallback |
| Error loading data | ”Could not load records. Try refreshing.” + retry action |
A generic “No results found” that appears for all four situations is a missed opportunity and a real usability failure.
Row Selection and Bulk Actions
When users need to act on multiple records, provide checkbox-based row selection. The standard pattern works like this:
- A header checkbox selects or deselects all visible rows.
- Individual row checkboxes appear on hover — or always, on touch devices where hover does not exist.
- A bulk action bar appears once at least one row is selected, showing the count and available actions (“3 records selected — Archive | Delete | Export”).
The bulk action bar should replace or overlay the filter row. It must not push the table down. Layout shift when a user selects a row is jarring and breaks focus.
Keyboard behavior matters too. Space toggles the focused row’s checkbox. Shift + Space extends the selection from the last selected row. Ctrl/Cmd + A selects all. These shortcuts are learnable for power users, but they require explicit implementation — the browser does not provide them for free.
Do
- Right-align numeric columns so digits line up and users can compare values by scanning vertically.
- Show the active sort column with a single directional indicator; clear it when sort is removed.
- Display filter state persistently as dismissible chips above the table.
- Provide a contextually appropriate empty state with a clear next action.
- Use skeleton screens for initial loads; use a table overlay for subsequent fetches.
Don't
- Center numeric values — it destroys at-a-glance comparison.
- Show sort arrows on every column header at once — it obscures which column is active.
- Hide active filters in a collapsed panel or clear them silently when a user navigates away.
- Display a single generic “No results” message regardless of whether the cause is missing data, an over-restricted filter, or a network error.
- Unmount and remount the full table skeleton on every filter or sort change.
Inline Editing
Inline editing means clicking a cell to edit its value without navigating to a detail page. This reduces round-trips and keeps users in context. It works best for simple edits — status toggles, short text, numeric fields. For multi-field edits or edits that need validation against other fields, a slide-over panel or modal is more appropriate.
A few implementation details make the difference between inline editing that feels polished and inline editing that feels broken:
- Activation: single-click or double-click — pick one and be consistent. Show a faint edit icon or a cursor change on hover to signal that the cell is editable.
- Commit:
EnterorTabcommits the value.Escapecancels and restores the original. Clicking outside the cell should prompt a save if the value changed. - Validation: validate on commit, not on every keystroke. Show the error inline below the cell — not in a toast that disappears before the user can read it.
- Undo: offer an undo path for destructive or hard-to-reverse edits. A short-lived undo toast or a “Revert changes” action both work.
Accessibility for Tables
Tables are one area where accessibility is both technically required and frequently broken. The foundation is correct HTML: table, thead, tbody, th scope="col" for column headers, and th scope="row" for row headers. Screen readers use the scope attribute to announce which column or row a cell belongs to. Without it, dense tables become incomprehensible.
Key WCAG 2.2 requirements for data tables:
- 1.3.1 Info and Relationships: the table structure must be programmatically determinable. Use real
tablemarkup, notdivgrids withrole="grid"— unless you implement the full ARIA grid interaction pattern. - 2.1.1 Keyboard: every interactive element — sort controls, filter inputs, row checkboxes, inline edit triggers, pagination — must be reachable and operable by keyboard alone.
- 2.4.7 Focus Visible (and 2.4.11 Focus Not Obscured, new in 2.2): focused cells and controls must have a visible focus indicator that is not obscured by sticky headers or bulk action bars.
- 4.1.2 Name, Role, Value: icon-only action buttons need an
aria-labelortitle. Active sort buttons should usearia-sort="ascending"oraria-sort="descending"on the activeth.
Responsive Tables
Standard tables break on narrow viewports. They either overflow horizontally or collapse into unreadable wrapping. Neither is a good default. Here are three modern approaches:
Horizontal scroll with a sticky first column. The simplest fix for wide tables on small screens. Wrap the table in an overflow container and pin the identifier column (name, ID) with position: sticky; left: 0. Users can scroll to see all columns while always knowing which row they are on.
Priority column hiding. Use container queries — not viewport media queries — to progressively hide lower-priority columns as the table’s container shrinks. Container queries are component-scoped: the table responds to its own available width, not the viewport. Assign each column a priority level in your data model and hide the lowest-priority ones first.
Card/list transform. For narrow viewports where even 2–3 columns are too many, transform each row into a stacked card or definition list. The column header becomes a visible inline label paired with the value. This works well on mobile but loses the side-by-side comparison that makes tables useful for power users.
The right choice depends on the table’s purpose. A user management table might hide “Last Login” and “Created Date” before hiding “Name” and “Status.” An analytics table might collapse to cards on mobile because the data is exploratory, not comparative.
Performance for Large Datasets
Rendering thousands of DOM nodes is expensive. For tables with more than a few hundred rows, virtualization is not optional — it is essential.
Windowed rendering (virtualization) means only rendering the rows currently visible in the viewport, plus a small buffer above and below. Libraries like TanStack Virtual handle the math. The user sees a full, scrollable list, but the DOM contains only 30–50 rows at any time.
Pagination vs. infinite scroll vs. load more. For data grids where users need to locate, sort, or jump to a specific record, explicit pagination with a page number and total count is the right model. It gives users a navigable address space. Infinite scroll works well for exploratory feeds but is disorienting in operational contexts where users need to return to a specific position. “Load more” is a reasonable middle ground for medium-scale lists where the primary action is progressive exploration.
Server-side operations: for large datasets, sort, filter, and search should run on the server. Client-side sorting of 50,000 records is technically possible but architecturally wrong. It requires transferring all the data to the client upfront, which is slow and wastes bandwidth for users who only look at the first page.
Design Token and Theming Considerations
Tables have many theming surfaces: row backgrounds (including alternating stripes and hover highlights), border colors, header backgrounds, selected row fills, and sticky column shadow overlays. A semantic token layer keeps all of this manageable.
Token hierarchy for a table component:
color.surface.table.header(maps to a primitive likeneutral.100)color.surface.table.row.default(maps toneutral.0or transparent)color.surface.table.row.hover(maps toneutral.50)color.surface.table.row.selected(maps tobrand.50)color.border.table(maps toneutral.200)
Dark mode tables should avoid pure black backgrounds and pure white text. Use luminance-step elevation instead. In dark mode, the header can be slightly lighter than the page background — the convention inverts from light mode. A page background of #0A0A0A with a table header of #1A1A1A creates a clear distinction without harsh contrast.
Alternating row stripes (zebra striping) improve scannability for dense tables with many columns. Keep the tonal difference subtle — a single OKLCH lightness step is enough. Heavy zebra striping creates a pattern that competes with hover and selected states.