A table is the place where good products quietly turn back into raw output. You build a clean dashboard, you nail the typography, and then there's an invoices screen — and the moment you drop rows into a default table, the whole thing reverts to looking like a phpMyAdmin export someone forgot to style. Centered numbers, a 1px border around every cell, the header row in the same weight and size as the data, fourteen columns fighting for attention. It reads like a database dump because it is one.
The reason this keeps happening is that tables feel like a solved problem. The browser gives you <table>, the data comes back from the API as an array of objects, you map over it, and you ship. The work feels done. But a table is not a container for data — it's an instrument for finding one value among hundreds. The default table optimizes for displaying everything equally. A good table optimizes for the search.
Walk through what the default does. Every cell gets the same font size and weight. Every column gets the same visual emphasis. Numbers and text are aligned the same way — usually left, sometimes centered, almost never correctly. There's a border on all four sides of every cell, so the grid itself becomes the loudest thing on the screen, louder than any value inside it.
Now think about how anyone actually uses a table. Nobody reads a table left-to-right, top-to-bottom, like prose. They have a question — which customer is overdue?, what did we charge in March?, which job failed? — and they hunt for the answer. They pick a column and they run their eye straight down it. That's the entire interaction. A table is a tool for one vertical sweep at a time.
The default table makes that sweep hard in three specific ways. Centered numbers don't share a right edge, so 9.99 and 1,240.00 start in different places and your eye can't compare magnitudes. Proportional figures mean the 1 is narrower than the 8, so even left-aligned numbers wobble. And the full grid of borders chops every column into boxes, breaking the unbroken vertical line your eye wants to follow.
Nobody reads a table; they pick a column and run their eye straight down it.
Fixing the spreadsheet look isn't about making the table prettier. It's about making that one vertical sweep effortless. Once you design for the scan instead of the dump, the borders get quieter, the numbers line up, and the thing starts to look like it was made by someone who cared.
People do not read tables — they scan columns. A reader picks one column and runs their eye down it to find a single value. Align and style for that vertical sweep: numbers right-aligned with tabular figures so digits stack by place value, labels left-aligned so words start at a shared edge, and headers kept quiet so they guide without competing. The job of a table is to make one value findable in one downward glance.
Alignment is the highest-leverage change you can make, and it costs almost nothing. The rule is short: numbers right, text left, and never center either one.
Numbers go right because that's how we compare them. When 9.99, 124.00, and 1,899.50 share a right edge, the decimal points line up, the ones column lines up, the thousands column lines up. Your eye reads magnitude as physical width — the longer number is literally further left. Center a column of numbers and you destroy all of that; now every value floats in its own little pool and comparing two of them means actually reading the digits.
Text goes left because words are read from a starting edge. A column of names, statuses, or descriptions should all begin at the same x-position so the eye drops down a clean vertical line. Centered text gives you a ragged left edge that the eye has to re-find on every row.
Here's the alignment in plain CSS:
.table th,
.table td {
text-align: left; /* default for text columns */
padding: 12px 16px;
}
/* numeric columns: right-align + lock digit width */
.table td.num,
.table th.num {
text-align: right;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
That tabular-nums line is the second half of the fix and the one most developers have never heard of. By default, most fonts ship proportional figures — the 1 is skinnier than the 0, the way it is in body text. That's fine in a sentence and quietly ruinous in a column, because 111 and 888 come out different widths and the digits in row three don't sit above the digits in row four. Switching to tabular figures forces every digit to the same fixed width, so the ones column stacks dead straight all the way down. Apply it to any column of numbers: currency, counts, percentages, durations, IDs.
Most quality UI fonts (Inter, system-ui on modern OSes, SF, Roboto) include tabular figures, so font-variant-numeric: tabular-nums just works. A few display or hand-drawn fonts don't ship them, in which case the property silently does nothing. If your numbers still wobble after adding it, your font lacks tnum — set numeric columns to a font that has it, or to a monospace stack as a fallback.
Money and dates deserve their own note because they're where alignment goes wrong most visibly.
For currency, right-align the value and keep the symbol attached to the number, not pinned to the left of the cell. A column where $ sits at the far left and the digits drift toward the right is the spreadsheet look in miniature — the symbol and its number have been divorced. Format the whole string ($1,240.00) and right-align it as one unit. If you have mixed currencies the symbol travels with each value and that's fine; the right edge still lines up the decimals.
For dates, pick one machine-friendly, fixed-width format and right-align if you're sorting by recency, or left-align if the date reads more like a label. The cardinal sin is the locale-default long date — January 4, 2026 next to December 11, 2025 — which has variable width, sorts wrong visually, and can't be scanned. Use 2026-01-04 or Jan 4, 2026 with a fixed pattern, and use tabular-nums here too so the day and year digits align.
Then there's the header row, which the default makes far too loud. Headers are signage, not content — they tell you which column is which and then they should get out of the way. The fix is to make them quieter than the data, not bolder:
.table th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #6b7280; /* muted gray, not near-black */
border-bottom: 1px solid #e5e7eb;
}
Small, muted, slightly tracked-out, with a single hairline rule underneath. The labels recede; the values — which are what you came for — become the loudest thing in their own table. This is the same instinct as One Loudest Thing applied inside a single component: the data wins, the chrome loses.
Right-align numbers with tabular figures and left-align text; that one pair of decisions does more for a table than any amount of color or border styling.
Once the columns line up, the next thing that separates a real table from a dump is vertical rhythm — how tall the rows are and how you separate them.
Default rows are usually too tight and too boxed. The fix is generous, consistent vertical padding and the lightest possible separators. Set row height with padding, not a fixed height, so a row that wraps doesn't clip. And follow the same multiples you use everywhere else: this is The 8-Point Grid living inside a table. Cell padding of 12px vertical, 16px horizontal is a reliable starting point for a comfortable data table; drop to 8px/12px for a dense, finance-style grid where the user is scanning hundreds of rows and wants more on screen at once.
Pick the density on purpose. Two honest defaults:
padding: 12px 16px, roughly a 44px row. Good for dashboards, settings, anything a user reads occasionally.padding: 8px 12px, roughly a 36px row. Good for power-user tables, logs, financial data, anything scanned heavily.What you should not do is split the difference at some arbitrary number like padding: 9px 13px. Tables are exactly the place where off-grid values accumulate into that subtly-wrong feeling. Multiples of 4 and 8, every time.
Now the separators — and this is where most spreadsheet looks are born. You have three options, and two of them are usually wrong:
The hairline approach keeps the vertical scan completely unobstructed — there are no vertical borders chopping your column into segments — while still giving the eye a faint horizontal track to stay on. Here's the whole pattern:
.table {
width: 100%;
border-collapse: collapse;
}
.table td {
border-bottom: 1px solid #f3f4f6; /* very light hairline */
}
.table tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: #f9fafb; /* gentle row highlight on hover */
}
No vertical lines anywhere. A hairline so light it's almost subliminal between rows. A whisper of background on hover so the user can confirm which row their cursor is on. That hover state is doing real work in a wide table — it's the modern, lighter replacement for zebra striping. Reach for zebra only when the table is genuinely wide (eight-plus columns) and the user has to trace a single record across the full width.
A table column is not one note played over and over; some cells carry more than others, and the row itself often has a primary value and a few supporting ones. Designing that hierarchy is what makes a table feel considered rather than merely aligned.
Start with the one column that matters. In almost every table there's a single column the user came to read — the name, the title, the amount, the status. Give that column slightly more weight: a heavier font, or a darker color, or simply more horizontal room. Everything else in the row is context for that one value. A common, clean pattern is a leading "identity" column rendered a touch bolder and darker, with the rest of the row in normal weight and a softer gray.
.table td.primary {
font-weight: 600;
color: #111827; /* near-black: the value you came for */
}
.table td.secondary {
color: #6b7280; /* muted: supporting context */
}
You can also stack primary and secondary information inside a single cell instead of spending two columns on it. A customer cell that shows the name on top and the email beneath it — the name in #111827 at normal size and the email in #6b7280 at 13px — collapses two columns into one and reads as a clean two-line unit. This is how you fight column sprawl: a fourteen-column table is almost always a six-column table where eight columns should have been paired up or moved into a detail view. Ask of every column, does the user scan this, or do they only need it once they've found the row? Scannable columns stay; lookup-only columns can be paired, hidden behind a row expand, or pushed to the detail page.
A single description or URL that runs long will stretch its column and wreck every other column's alignment. Constrain it: give the cell a max-width, then overflow: hidden; text-overflow: ellipsis; white-space: nowrap; so it truncates with an ellipsis and the rest of the table keeps its rhythm. Show the full value on hover with a title attribute or a tooltip. Never let one runaway string dictate the layout of the whole table.
Truncation deserves a real decision rather than a default. Wrapping a long cell to two or three lines breaks the row rhythm and makes the scan harder; truncating with an ellipsis keeps every row the same height and the scan intact, at the cost of hiding part of the value. For scannable tables, truncate and keep the rhythm. The exception is the one primary column — if truncating the thing the user came to read would hide the part that distinguishes one row from another, give that column more room instead.
A fourteen-column table is almost always a six-column table where eight columns should have been paired up or hidden.
Two more things separate a finished table component from a working one, and both live at the edges of the happy case.
The first is states. A table is a data view, and every data view needs more than its ideal, full-of-rows version — it needs an empty state, a loading state, and an error state. That's The Four States Rule, and tables are where it's skipped most often. The empty table is the big one: a brand-new user, or anyone who's filtered down to zero results, sees a bare header row sitting on top of nothing, which reads like the page broke. Replace it with a real empty state — a short line of what this table will hold and a button to add the first row, or a "no results, clear filters" message when a filter emptied it. For loading, skeleton rows (gray bars the shape of your real cells) beat a centered spinner because they hold the layout steady and tell the user what's coming. For error, say what failed and offer a retry, in the table's own space, not a toast that vanishes.
The second is sorting. If a column is sortable, the user needs to know it before they click and needs to know the current sort after. Make sortable headers obviously interactive — cursor: pointer, a hover state — and show the active sort with a small arrow on the one column that's sorted. Show the arrow on the active column only; an arrow on every header is noise that defeats the point. And whatever the default sort is, make it the sort the user most likely wants — newest first for activity, highest amount for billing, alphabetical for a directory. The default sort is part of Answer the First Question: the right initial order can put the row they came for at the top before they touch a single control.
Then the question under all of this: should it be a table at all? Tables are for comparing many records across the same dimensions — when the user's job is to scan a column and compare values down it, a table wins every time. But a table is the wrong tool when each item is rich and mostly viewed on its own rather than compared. A few pricing options with descriptions and a feature list are cards, not rows. A feed of distinct events is a list. A single record's attributes are a detail layout, not a one-row table. Reach for a table when there's a column worth scanning. When there isn't — when the user looks at one thing at a time instead of comparing many — cards or a list will almost always read better.
Use a table when the user scans a column to compare records; when they view one rich item at a time, use cards or a list instead.
Find each column of numbers — currency, counts, percentages, durations, IDs — and set text-align: right plus font-variant-numeric: tabular-nums. Success looks like every decimal point and ones column stacking in a dead-straight vertical line down the column.
Make headers smaller and muted, not bolder: 12px, weight 600, uppercase, letter-spacing 0.04em, a gray like #6b7280, with one hairline rule underneath and no vertical borders. The data should now be the loudest thing in the table.
Remove all vertical borders and any heavy cell boxes. Keep a single very light border-bottom between rows and add a gentle hover background. The full Excel grid should be gone.
Set cell padding to a consistent multiple of 8 — 12px 16px for comfortable, 8px 12px for dense — and pick one density on purpose. No off-grid values like 9px or 13px anywhere in the table.
Find the one column that can hold a long string and constrain it: max-width, then overflow: hidden; text-overflow: ellipsis; white-space: nowrap, with the full value on hover. Confirm every row is now the same height.
Replace the bare-header-on-nothing view with a real empty state: one line describing what the table holds plus a button to add the first record, and a separate "no results" message for when a filter emptied it. Test it by clearing your data and by filtering to zero.
A table is not a place to put your data — it's an instrument for finding one value in a single downward sweep, and everything you style should make that sweep faster.