Tailwind CSS Handbook v4.3

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#

HTML
<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>
ElementWhy 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.

Loading preview…
  • divide-y divide-gray-200 on <tbody> draws one hairline between rows and none after the last one — in v4 divide-* targets :not(:last-child).
  • overflow-hidden rounded-lg border on a wrapper is how the table gets rounded corners; border-radius on a border-collapse: collapse table does not clip its cells.
  • The header row is bg-gray-50 with uppercase and tracking-wide at text-xs — small caps read as structure rather than content.

Striped rows and hover#

HTML
<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-50 on the row so the highlight follows focus too.

Alignment and numbers#

ContentAlignment
Text, names, identifiersLeft (text-left)
Numbers, currency, dates you compareRight (text-right tabular-nums)
Status badges, avatars, iconsLeft or centre — be consistent
Row actionsRight, 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.

HTML
<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:

DensityCell paddingRow height feel
Compactpx-3 py-1.5 text-smDashboards, log views, 15+ rows on screen
Comfortablepx-4 py-3 text-smMost application tables
Roomypx-6 py-4 text-smShort 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.

Loading preview…

Scroll it sideways and up and down: the header stays put in both directions.

  1. The wrapper scrolls. max-h-64 overflow-auto — a height limit is what makes a sticky header meaningful.
  2. The table sets a floor. min-w-3xl stops the columns from collapsing so the wrapper actually overflows.
  3. sticky top-0 goes on the th, not on thead — position: sticky on a row group is not reliable across browsers.
  4. Sticky cells need their own background. Without bg-gray-50 the 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.

HTML
<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.
  • group on the th plus group-aria-[sort=descending]:rotate-180 on the icon flips the arrow from the cell's state, so one icon covers both directions.
  • Valid values are ascending, descending, other and none. Omit the attribute entirely on unsorted columns rather than setting none on 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.

Loading preview…

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.

Loading preview…

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 no scope. These are two attributes' worth of effort and the whole accessibility story.
  • overflow-x-auto with no min-w-* on the table. The table shrinks to fit and never scrolls; the columns just get unreadable.
  • sticky top-0 on <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-nowrap on 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.
  • Overflow & Layering — the scroll wrapper in context
  • Cards — the stacked-on-mobile alternative
  • Text Styling — tabular-nums and other numeric variants
  • Sizing — min-w-* and the container scale
  • Borders — divide-*, border-separate and hairlines