Data table design is the work of deciding what a table shows, how it behaves as the dataset grows, and what happens when someone sorts, filters, selects, or edits a row. Most tables fail from an unmade decision, not a missing pattern.
So this post is ten decisions, in the order you hit them, each naming what it costs on the engineering side. Most table advice stops at the design file. The constraints that decide what a table can honestly offer live on the other side of it, in the query.
One boundary before we start. This post covers the component. How someone reaches the table, what opens when they click a row, and who may see it belong to the post on the shell around the work.
1. Data table design starts with where the data lives
Client side means the browser already holds every row. Sorting is instant and free. It can be multi column, because sorting an array by two keys costs nothing. Filtering is a re render. Select all is unambiguous, because every row is right there.
Server side means every sort, filter, and page is a request. Sorting has latency, so it needs a loading state. Multi column sort may not be available at all, because the query behind it does not support it. Search may be limited to indexed columns. Select all becomes ambiguous, because the rows the browser holds are not the rows that match.
The consequence designers miss: a spec that promises sort on every column, multi column sort, and free text search across all fields has quietly assumed client side data. On a large dataset, that spec is not expensive. It is impossible.
Ask one question before drawing anything. How many rows does the ninety fifth percentile customer have? Not the demo account. The demo account has forty rows, and every design works on forty rows.
| Dataset size band | Where the work happens | What the table can honestly offer | |---|---|---| | A few hundred rows | The browser, holding every row | Instant sort on any column, multi column sort, free text search across every field, unambiguous select all | | A few thousand rows | Usually still the browser, at a real payload cost on first load | The same capabilities, but first paint slows and virtualization starts to matter | | Hundreds of thousands of rows | The database, one request per sort, filter, and page | Sort on indexed columns, filter on supported fields, paged results, and a loading state on every interaction |
One design move falls out of this: when the data is server side, design the waiting. A sort that takes a round trip needs a loading treatment on the rows themselves, not a full page spinner that throws away the reader's place.
2. Pagination, infinite scroll, and virtualization answer different questions
These three get listed as interchangeable options. They answer three separate questions.
Pagination gives a view a stable address and a sense of scale. Page three of forty tells the reader how much there is, and it survives a refresh and the back button. Infinite scroll destroys both. There is no page to link to, no sense of depth, and the footer moves away every time you approach it. It suits feeds, not work.
Virtualization keeps one continuous scroll while capping how many rows are actually rendered. Browser find in page only searches rows currently in the DOM, so the reader's search reflex fails silently and they conclude the record is gone.
| Approach | What it gives you | What it costs you | Choose it when | |---|---|---|---| | Pagination | A stable address per view, a sense of total scale, correct refresh and back button | A click between pages, and a scroll position that resets | Someone works through the list item by item | | Infinite scroll | Uninterrupted browsing | No linkable page, no sense of depth, an unreachable footer | The content is a feed, not a task | | Virtualization | One unbroken scroll over a large set | Find in page misses rows not rendered, and jump to row has to be built | The interaction genuinely needs one continuous list |
3. Select all means two things, and only one of them is safe
Picture the header checkbox on page one of forty.
Reading one: the twenty five rows visible on this page. Reading two: all twelve thousand rows matching the current filter.
Those are two different destructive actions. Deleting twenty five things and deleting twelve thousand things should not share a confirmation dialog.
The pattern that resolves it is an escalation. The header checkbox selects the visible page. A second, explicit affordance then appears above the rows saying all rows on this page are selected, and offering to select all rows matching the filter instead. The reader takes that step on purpose, and can undo it.
Confirmation copy carries the same job. State the count and the scope, not the object type. "Delete 12,480 invoices matching your current filters" is a different sentence from "Delete these invoices", and only one can be checked before clicking.
Then there is the state everyone forgets. What happens to a selection when the reader changes the filter, or moves to page two? Keeping it hides rows the reader can no longer see. Dropping it silently loses work. Either answer can be right, but decide it deliberately rather than shipping whatever the library does by default.
4. If the view is worth returning to, it belongs in the URL
Serialize the sort, the active filters, the current page, and the column layout into the address bar.
Four things come free the moment you do. The view is shareable, it survives a refresh, the back button behaves the way the reader expects, and support can ask a customer to paste the link instead of describing what they filtered.
Here is the reframe that matters. A saved view is a serialization problem before it is a UI problem. If the state already lives in the URL, saved views are just naming and storing a string. If it does not, saved views become a second state system that drifts out of sync with the first one.
That changes something in the design file too. The filter bar has to be lossless: every filter the reader can apply must be representable in the URL. A filter panel that holds state the address bar cannot express is a spec defect, not a detail for engineering to sort out later.
There is a real limit here. A very long filter expression outgrows what belongs in a URL, and at that point the view needs a stored identifier instead. Name that threshold in the spec rather than pretending the URL scales forever.
5. Columns: how many, which one flexes, and what truncation hides
Column count is decided by comparison, not taste. A table exists so someone compares rows on a few attributes, and every column past those is scrolled past. The practical ceiling is what fits without horizontal scroll at the width your users actually work at.
Then pick which single column flexes and absorbs the leftover width, usually name or description. Without that rule every column shares the slack, and numeric columns end up ragged and hard to compare.
Truncation is the most common real table failure, and it is a decision rather than a default. Use middle truncation for identifiers where both ends carry meaning, like a file path or an order reference, and end truncation for prose. Never truncate a value the reader has to act on without a way to see it in full.
The full value has to be reachable. A title attribute is not enough, because it fails on touch and for anyone on a keyboard. Plan a real tooltip or an expandable cell and write it into the spec.
Load the table with the longest real name in your production database, not the seed data. If a value someone has to act on is cut off, the truncation rule is wrong.
Density and sticky chrome
Offer density as a real setting rather than one fixed row height. A reader scanning for one outlier and a reader reading every row want different things from the same table.
A sticky header is table stakes on anything taller than the viewport. A frozen first column earns its place only when the reader needs row identity while scrolling sideways, and it costs layout complexity.
Number alignment and tabular figures live in the post on the visual craft layer of a data screen.
6. Editing in the row, and what it costs
This decision is narrow: whether a cell is editable in place, and what that implies. Where a full detail view opens belongs to the app shell post.
Inline editing is right for single field corrections made often. A status, an owner, a quantity. It removes the round trip of open, edit, close.
It is wrong when the edit has dependencies, needs validating against other fields, or is destructive. A cell has no room to explain why a value was rejected.
An editable cell needs four things a read only cell does not: a visible edit affordance, a save semantic the reader can predict, a place to show a field level error, and an undo path.
Save semantics are the real decision here. Save on blur feels fast and quietly loses data when the request fails after the reader has moved on. An explicit confirm is slower and honest. An optimistic update with rollback sits in the middle, and it needs a designed rollback state, not a toast nobody reads.
Expandable rows are the escape hatch when an edit, or the detail it depends on, outgrows the cell. They break scanning while open, so decide up front whether more than one can be open at a time.
7. Skeleton rows and three different empty states
Loading, empty, and stale at the tile level live in the post on how a data screen behaves when data is missing or late. This is the table version only.
Skeleton rows beat a spinner. They hold the column widths still and show how many rows are coming, which a spinner cannot. A spinner over a table collapses the layout, then snaps it back when data lands.
An empty table is one of three situations, and telling them apart belongs to that post. What is specific to a table is where the message goes and what it does to the layout. The empty state occupies the row area only, so the header, the filter bar, and the column widths stay exactly where they were, and the recovery action sits inside that row area rather than on a toolbar the reader has already scrolled past.
8. Accessibility is a table decision, not a compliance footnote
Start with semantic markup. Real header cells with a scope, so a screen reader announces the column name alongside each cell value. Without it, a cell reading "Pending" carries no information. This is exactly what the div grid shortcut discards.
Sorted state has to be announced, not just drawn. An arrow icon communicates nothing to a screen reader, so the sortable header needs to expose that it is sortable and which direction it is currently sorted in.
Filtering is the quiet one. Changing a filter changes the row count, and nothing announces it except rows disappearing visually. A polite live region saying how many rows now match is the fix, and it belongs in the spec, not in a late accessibility pass.
Then focus management. When the row a reader was working on gets deleted, focus has to land somewhere deliberate, usually the next row. By default focus falls back to the document body, so the next tab press usually starts the keyboard user over from the beginning.
The honest tradeoff: headless libraries that render a div grid make virtualization and column resizing easier, and hand you the accessibility work as homework. Know which trade you made.
9. Mobile: stacking a table destroys the reason it was a table
The standard advice is to stack each row into a card on small screens. It deserves more pushback than it gets.
Stacking removes column alignment, which was the entire reason the data was in a table. Comparing two rows becomes impossible the moment they sit one above the other as separate cards.
Horizontal scroll preserves comparison but hides row identity, which is why a frozen identity column matters more on a phone than on a desktop.
The third option nobody names is a prioritized subset. Show the two or three columns that carry the decision, and put the rest behind a detail view for a single row.
That has a prerequisite. Rank the columns by which ones the reader actually compares on. If nobody can rank them, the table has no primary job, and that is the real finding.
For the finance specific version of the table versus card call, see the post on patterns for money screens.
10. Build it, use a headless library, or buy a grid
Hand rolled is right for a short, mostly static list. Reaching for a dependency there costs more than it saves. Past that, TanStack Table is the common example of the headless category and AG Grid of the enterprise grid category. Both exist because the feature list they cover is genuinely hard, so choosing one is not a failure of craft. It is a correct read of scope.
| Approach | What you own | What it costs | Choose it when | |---|---|---|---| | Hand rolled | All of it: markup, styling, behavior | Every feature is yours to build and maintain, and the fifth one hurts | A short, mostly static list with few columns | | Headless library | Markup and styling, while the library owns state | A dependency, an API to learn, and the library's model of a row | Sort, filter, selection, and pagination have to work together | | Enterprise grid | Configuration, mostly | A license fee, design control, and the grid's opinions become your interface | Grouping, pivoting, cell editing, and export at scale are requirements |
The question that settles it: is the table a feature of your product, or is it your product? A table that is the product justifies a grid. A table that lists five settings does not.
If you are at the point where the table is the product, and nobody on the team owns that decision, that is the B2B product work I do.