Tables
Readable data tables — semantic markup, striping, sticky headers, sortable columns, alignment conventions and mobile strategies.
A data table is one of the few places where the HTML element genuinely matters: <table> gives you row and column relationships that a screen reader can navigate cell by cell, and no arrangement of <div>s reproduces that.
Semantic markup#
<table class="w-full text-left text-sm">
<caption class="caption-bottom pt-3 text-xs text-gray-500">
Invoices issued in the last 30 days
</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Client</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">INV-001</th>
<td>Globex</td>
<td>$1,200.00</td>
</tr>
</tbody>
</table>| Element | Why it is there |
|---|---|
<caption> | The table's accessible name. caption-bottom moves it below without changing the DOM order |
<thead> / <tbody> | Lets the browser repeat headers when printing and keeps sticky headers simple |
scope="col" | Ties a header to its column |
scope="row" | Marks the cell that identifies the row — usually the first column |
Base styling#
Preflight already sets border-collapse: collapse on tables, so borders between cells will not double up.
<div class="p-6">
<div class="overflow-hidden rounded-lg border border-gray-200">
<table class="w-full text-left text-sm">
<caption class="sr-only">Recent invoices</caption>
<thead class="bg-gray-50 text-xs tracking-wide text-gray-600 uppercase">
<tr>
<th scope="col" class="px-4 py-3 font-medium">Invoice</th>
<th scope="col" class="px-4 py-3 font-medium">Client</th>
<th scope="col" class="px-4 py-3 font-medium">Status</th>
<th scope="col" class="px-4 py-3 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr class="odd:bg-white even:bg-gray-50 hover:bg-indigo-50">
<th scope="row" class="px-4 py-3 font-medium text-gray-900">INV-001</th>
<td class="px-4 py-3 text-gray-600">Globex</td>
<td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-800 ring-1 ring-green-600/20"><span class="size-1.5 rounded-full bg-green-600"></span>Paid</span></td>
<td class="px-4 py-3 text-right tabular-nums text-gray-900">$1,200.00</td>
</tr>
<tr class="odd:bg-white even:bg-gray-50 hover:bg-indigo-50">
<th scope="row" class="px-4 py-3 font-medium text-gray-900">INV-002</th>
<td class="px-4 py-3 text-gray-600">Initech</td>
<td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2 py-1 text-xs font-medium text-amber-800 ring-1 ring-amber-600/20"><span class="size-1.5 rounded-full bg-amber-600"></span>Pending</span></td>
<td class="px-4 py-3 text-right tabular-nums text-gray-900">$840.50</td>
</tr>
<tr class="odd:bg-white even:bg-gray-50 hover:bg-indigo-50">
<th scope="row" class="px-4 py-3 font-medium text-gray-900">INV-003</th>
<td class="px-4 py-3 text-gray-600">Soylent</td>
<td class="px-4 py-3"><span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-800 ring-1 ring-red-600/20"><span class="size-1.5 rounded-full bg-red-600"></span>Overdue</span></td>
<td class="px-4 py-3 text-right tabular-nums text-gray-900">$12.00</td>
</tr>
</tbody>
</table>
</div>
</div>divide-y divide-gray-200on<tbody>draws one hairline between rows and none after the last one — in v4divide-*targets:not(:last-child).overflow-hidden rounded-lg borderon a wrapper is how the table gets rounded corners;border-radiuson aborder-collapse: collapsetable does not clip its cells.- The header row is
bg-gray-50withuppercaseandtracking-wideattext-xs— small caps read as structure rather than content.
Striped rows and hover#
<tr class="odd:bg-white even:bg-gray-50 hover:bg-indigo-50">Tailwind emits hover: after odd:/even: in the stylesheet, so the hover colour wins on both stripe colours without any specificity tricks. Two cautions:
- Striping is a reading aid for wide tables. On a three-column table it is decoration, and it lowers the contrast of every second row.
- Row hover is invisible to keyboard users. If a row is clickable, put the link in a cell and use
has-[a:focus-visible]:bg-indigo-50on the row so the highlight follows focus too.
Alignment and numbers#
| Content | Alignment |
|---|---|
| Text, names, identifiers | Left (text-left) |
| Numbers, currency, dates you compare | Right (text-right tabular-nums) |
| Status badges, avatars, icons | Left or centre — be consistent |
| Row actions | Right, in the last column |
Right alignment lines up the decimal points; tabular-nums makes every digit the same width so the columns stay put as values change. Align the column header the same way as its cells, or the header appears to belong to the neighbouring column.
<th scope="col" class="px-4 py-3 text-right font-medium">Amount</th>
<td class="px-4 py-3 text-right tabular-nums">$1,200.00</td>Density#
Pick two densities and use padding alone to switch between them:
| Density | Cell padding | Row height feel |
|---|---|---|
| Compact | px-3 py-1.5 text-sm | Dashboards, log views, 15+ rows on screen |
| Comfortable | px-4 py-3 text-sm | Most application tables |
| Roomy | px-6 py-4 text-sm | Short tables with two-line cells |
Keep the horizontal padding on the first and last columns aligned with the surrounding card padding, or the table looks inset.
Horizontal scrolling and sticky headers#
Wide tables should scroll inside their own container rather than squeezing columns or breaking the page layout. The wrapper scrolls; the table needs a min-w-* or it will simply shrink to fit and never overflow.
<div class="p-6">
<div class="max-h-64 overflow-auto rounded-lg border border-gray-200">
<table class="w-full min-w-3xl border-separate border-spacing-0 text-left text-sm">
<caption class="sr-only">Deployments</caption>
<thead>
<tr>
<th scope="col" class="sticky top-0 z-10 border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-xs font-medium tracking-wide text-gray-600 uppercase">Commit</th>
<th scope="col" class="sticky top-0 z-10 border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-xs font-medium tracking-wide text-gray-600 uppercase">Branch</th>
<th scope="col" class="sticky top-0 z-10 border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-xs font-medium tracking-wide text-gray-600 uppercase">Environment</th>
<th scope="col" class="sticky top-0 z-10 border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-xs font-medium tracking-wide text-gray-600 uppercase">Author</th>
<th scope="col" class="sticky top-0 z-10 border-b border-gray-200 bg-gray-50 px-4 py-2.5 text-right text-xs font-medium tracking-wide text-gray-600 uppercase">Duration</th>
</tr>
</thead>
<tbody class="bg-white">
<tr><th scope="row" class="border-b border-gray-100 px-4 py-2.5 font-mono text-xs font-normal text-gray-900">a1c9f04</th><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">main</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">production</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">avery</td><td class="border-b border-gray-100 px-4 py-2.5 text-right tabular-nums text-gray-600">42s</td></tr>
<tr><th scope="row" class="border-b border-gray-100 px-4 py-2.5 font-mono text-xs font-normal text-gray-900">7de3b12</th><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">feat/search</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">preview</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">kim</td><td class="border-b border-gray-100 px-4 py-2.5 text-right tabular-nums text-gray-600">1m 04s</td></tr>
<tr><th scope="row" class="border-b border-gray-100 px-4 py-2.5 font-mono text-xs font-normal text-gray-900">c40a8ee</th><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">fix/typo</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">preview</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">sam</td><td class="border-b border-gray-100 px-4 py-2.5 text-right tabular-nums text-gray-600">38s</td></tr>
<tr><th scope="row" class="border-b border-gray-100 px-4 py-2.5 font-mono text-xs font-normal text-gray-900">91bd77a</th><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">main</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">production</td><td class="border-b border-gray-100 px-4 py-2.5 text-gray-600">avery</td><td class="border-b border-gray-100 px-4 py-2.5 text-right tabular-nums text-gray-600">51s</td></tr>
<tr><th scope="row" class="px-4 py-2.5 font-mono text-xs font-normal text-gray-900">2f0ac31</th><td class="px-4 py-2.5 text-gray-600">chore/deps</td><td class="px-4 py-2.5 text-gray-600">preview</td><td class="px-4 py-2.5 text-gray-600">kim</td><td class="px-4 py-2.5 text-right tabular-nums text-gray-600">29s</td></tr>
</tbody>
</table>
</div>
</div>Scroll it sideways and up and down: the header stays put in both directions.
- The wrapper scrolls.
max-h-64 overflow-auto— a height limit is what makes a sticky header meaningful. - The table sets a floor.
min-w-3xlstops the columns from collapsing so the wrapper actually overflows. sticky top-0goes on theth, not onthead—position: stickyon a row group is not reliable across browsers.- Sticky cells need their own background. Without
bg-gray-50the rows scroll straight through them.
Sortable columns#
aria-sort on the header cell is the accessible state, and it doubles as the styling hook. Exactly one column carries it at a time; the rest carry none.
<th scope="col" aria-sort="ascending" class="group px-4 py-3 text-left">
<button type="button" class="inline-flex items-center gap-1 font-medium text-gray-900 hover:text-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Client
<svg class="size-3.5 text-gray-400 transition group-aria-[sort=descending]:rotate-180" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 19V5m0 0-5 5m5-5 5 5" />
</svg>
</button>
</th>- The clickable thing is a
<button>inside the header cell, not the<th>itself — a cell is not focusable or keyboard-operable. groupon thethplusgroup-aria-[sort=descending]:rotate-180on the icon flips the arrow from the cell's state, so one icon covers both directions.- Valid values are
ascending,descending,otherandnone. Omit the attribute entirely on unsorted columns rather than settingnoneon all of them. - Announce the result of the sort with a live region — "Sorted by client, ascending" — because the table content changes without any focus moving.
Responsive strategies#
There are two honest answers for a table on a 375px screen.
Scroll it. Keep the table intact inside overflow-x-auto. This preserves every relationship, costs nothing, and is right for dense data people came to compare. Add a hint that it scrolls; horizontal scroll is easy to miss.
Stack it. Turn each row into a card. Display utilities can do it without duplicating any markup: the row becomes a block, each cell becomes a flex line, and before:content-[attr(data-label)] renders the column name from an attribute on the cell.
<div class="p-6">
<table class="w-full text-sm">
<caption class="sr-only">Team members</caption>
<thead class="sr-only md:not-sr-only">
<tr>
<th scope="col" class="px-4 py-2 text-left font-medium text-gray-600">Name</th>
<th scope="col" class="px-4 py-2 text-left font-medium text-gray-600">Role</th>
<th scope="col" class="px-4 py-2 text-left font-medium text-gray-600">Location</th>
</tr>
</thead>
<tbody>
<tr class="mb-3 block rounded-lg border border-gray-200 bg-white p-4 md:mb-0 md:table-row md:rounded-none md:border-0 md:border-b md:p-0">
<th scope="row" class="block text-left font-semibold text-gray-900 md:table-cell md:px-4 md:py-3 md:font-medium">Avery Diaz</th>
<td class="flex justify-between gap-4 py-1 text-gray-600 before:font-medium before:text-gray-500 before:content-[attr(data-label)] md:table-cell md:px-4 md:py-3 md:before:content-none" data-label="Role">Engineering</td>
<td class="flex justify-between gap-4 py-1 text-gray-600 before:font-medium before:text-gray-500 before:content-[attr(data-label)] md:table-cell md:px-4 md:py-3 md:before:content-none" data-label="Location">Lisbon</td>
</tr>
<tr class="mb-3 block rounded-lg border border-gray-200 bg-white p-4 md:mb-0 md:table-row md:rounded-none md:border-0 md:border-b md:p-0">
<th scope="row" class="block text-left font-semibold text-gray-900 md:table-cell md:px-4 md:py-3 md:font-medium">Kim Alvarez</th>
<td class="flex justify-between gap-4 py-1 text-gray-600 before:font-medium before:text-gray-500 before:content-[attr(data-label)] md:table-cell md:px-4 md:py-3 md:before:content-none" data-label="Role">Design</td>
<td class="flex justify-between gap-4 py-1 text-gray-600 before:font-medium before:text-gray-500 before:content-[attr(data-label)] md:table-cell md:px-4 md:py-3 md:before:content-none" data-label="Location">Toronto</td>
</tr>
</tbody>
</table>
</div>The mechanics: block on the <tr> and the cells breaks the table layout on small screens, md:table-row and md:table-cell restore it, and the header row is sr-only until then — hidden visually but still announced, because the cells still reference it.
Empty state#
An empty table body is a dead end. Fill it with one row spanning every column.
<div class="p-6">
<div class="overflow-hidden rounded-lg border border-gray-200">
<table class="w-full text-left text-sm">
<caption class="sr-only">Invoices</caption>
<thead class="bg-gray-50 text-xs tracking-wide text-gray-600 uppercase">
<tr>
<th scope="col" class="px-4 py-3 font-medium">Invoice</th>
<th scope="col" class="px-4 py-3 font-medium">Client</th>
<th scope="col" class="px-4 py-3 text-right font-medium">Amount</th>
</tr>
</thead>
<tbody class="bg-white">
<tr>
<td colspan="3" class="px-4 py-12 text-center">
<svg class="mx-auto size-8 text-gray-300" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6M8 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"/></svg>
<p class="mt-3 text-sm font-medium text-gray-900">No invoices yet</p>
<p class="mt-1 text-sm text-gray-500">Invoices appear here once you send your first one.</p>
<button type="button" class="mt-4 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">New invoice</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>Keep the header row visible: it tells the user what would be here, and it stops the layout jumping when the first row arrives. Distinguish "nothing yet" from "nothing matched your filter" — the second needs a clear filter action, not a create button.
Common mistakes#
<div>grids pretending to be tables. No row/column relationships, nothing for a screen reader to navigate.<table>used for page layout. It was never the answer, and it announces structure that does not exist.- No
<caption>and noscope. These are two attributes' worth of effort and the whole accessibility story. overflow-x-autowith nomin-w-*on the table. The table shrinks to fit and never scrolls; the columns just get unreadable.sticky top-0on<thead>. Put it on each<th>.- A sticky header with no background. Content scrolls through it.
- Sorting controls on the
<th>itself. Use a<button>inside the cell. - Status shown only by colour. Add the word — "Paid", "Overdue" — not just a green or red dot.
whitespace-nowrapon every cell. It guarantees a scrollbar; apply it only to columns that must not wrap, like dates and IDs.- Striping a three-column table. It adds noise and reduces contrast without helping anyone track a row.
Related topics#
- Overflow & Layering — the scroll wrapper in context
- Cards — the stacked-on-mobile alternative
- Text Styling —
tabular-numsand other numeric variants - Sizing —
min-w-*and the container scale - Borders —
divide-*,border-separateand hairlines