Skip to main content
ASoc
Tutorial

A Tailwind Data Table Audit: One Real Table, Two Real Defects

The only <table> in this codebase, read cell by cell: what it gets right, the missing caption and text-free checkmarks it shipped with, and the fix.

The ASoc Team9 min read

A Tailwind data table is a plain HTML <table> styled with utility classes — no component library required for the common case of rows, headers and a horizontal scroll on narrow screens. The markup does the accessibility work for free if you keep it semantic; the two places that actually go wrong are the responsive wrapper and anything that isn't plain text inside a cell, and this storefront's own pricing-page table had both.

The one real table in this codebase

Grep src/ for <table and exactly one component matches: LicenseSummary.tsx, the license-comparison grid on /pricing. It's a good specimen precisely because it's small enough to read in full and old enough to have accumulated the two mistakes below before this post found them.

// src/components/organisms/LicenseSummary.tsx — the structure
<div className="overflow-x-auto rounded-3xl border border-stroke-secondary dark:border-gray-700">
  <table className="w-full min-w-[560px] text-left text-sm">
    <thead>
      <tr className="border-b border-stroke-secondary bg-gray-50 text-title-color dark:border-gray-700 dark:bg-gray-800 dark:text-white/90">
        <th scope="col" className="px-6 py-4 font-medium">Use case</th>
        <th scope="col" className="px-4 py-4 text-center font-medium">Free (MIT)</th>
        {/* … */}
      </tr>
    </thead>
    <tbody>
      {ROWS.map((r) => (
        <tr key={r.use} className="border-b border-stroke-secondary last:border-0 dark:border-gray-700">
          <td className="px-6 py-4 text-text-color dark:text-white/80">{r.use}</td>
          {/* … */}
        </tr>
      ))}
    </tbody>
  </table>
</div>

Three things here are correct and worth copying as-is: scope="col" on every header cell (a screen reader announces "Free (MIT), column header" instead of just reading the text), last:border-0 instead of a border-collapse reset (Tailwind's border utilities apply per-side, so the last row's bottom border has to be turned off explicitly or every row gets one), and the wrapping overflow-x-auto div rather than shrinking the table itself.

The responsive pattern, and why it's a wrapper div and not the table

min-w-[560px] on the <table> plus overflow-x-auto on its parent is the whole responsive story: below 560px the table doesn't reflow or drop columns, it scrolls horizontally inside its rounded border. That's a deliberate trade, not a default — a 4-column comparison table has no column worth hiding (every cell here is a single MIT/Single/Full-Stack yes-or-no), so collapsing to a card layout would need to duplicate every row's headers per card. Horizontal scroll keeps one table, one set of headers, and a native scroll gesture nobody has to be taught.

The reason min-w-[560px] lives on the <table> and overflow-x-auto lives on the <div> around it, not combined onto one element: a table without an explicit minimum width happily shrinks its columns until text wraps into an unreadable stack, and overflow-x-auto on the table itself does nothing until something inside refuses to shrink. The wrapper is what makes the scrollbar appear instead of the columns compressing.

ApproachWhat happens below the breakpointHeadersBest for
overflow-x-auto wrapper + min-w table (used here)Horizontal scroll, layout unchangedStay attached to columnsTables where every column matters at every width
Hide columns with hidden md:table-cellNarrow columns disappear entirelyOnly visible ones shownTables with a clear primary/secondary column split
Reflow to stacked cards (grid + per-row labels)Each row becomes a labeled cardRepeated as a label per fieldTables with few rows and many fields per row
Data-table library (TanStack Table, AG Grid)Depends on the library's own responsive modeManaged by the libraryTables that also need sort/filter/pagination state

This repo's own architecture-decision post on the fourth row's territory is React UI libraries: when to reach for one and when not to — 91 components, zero UI-library dependencies, is the running theme, and a single static comparison table is squarely in "not."

The two defects this audit found

No <caption>. A sighted visitor gets the context from the <SectionHeading> two elements above the table ("What each license allows"), but a screen reader user who jumps directly to the table in their landmarks list hears "table, 4 columns, 4 rows" with no indication of what it's a table of. A <caption> fixes exactly that gap and, unlike a heading, is programmatically tied to the table element itself:

<table className="w-full min-w-[560px] text-left text-sm">
  <caption className="sr-only">
    What each ASoc license tier allows, by use case
  </caption>
  <thead>{/* … */}</thead>
</table>

sr-only keeps it invisible for sighted users, who already have the heading — the caption is purely an assistive-tech affordance, not a duplicate visual label.

Raw Unicode marks with no text alternative. Every cell in the three tier columns rendered a bare "✓" or "✗" character, distinguished visually by color (text-success-600 vs text-gray-500) and by the glyph itself. The glyph should be enough — but Unicode check marks and crosses are announced inconsistently across screen readers, and some skip an unadorned entirely because it has no assigned semantic role. The fix pairs the visual glyph with a hidden, unambiguous word:

const mark = (ok: boolean) => (
  <>
    <span aria-hidden="true">{ok ? "✓" : "✗"}</span>
    <span className="sr-only">{ok ? "Included" : "Not included"}</span>
  </>
);

aria-hidden="true" removes the glyph from the accessibility tree entirely — screen readers read only the sr-only text, sighted users see only the glyph, and nothing changes visually. Both fixes ship with this post, the same convention the accordion post and the tabs post used: pixel-neutral accessibility fixes ship immediately, layout-affecting ones get specified and deferred.

Sticky headers, if your table scrolls vertically too

This repo's tables are always short enough to fit without vertical scroll, so none of them need this — but it's the next thing anyone building a taller admin table reaches for, and it's two classes once the horizontal-scroll wrapper above is already in place:

<div className="max-h-[70vh] overflow-auto rounded-3xl border">
  <table className="w-full min-w-[560px] text-left text-sm">
    <thead className="sticky top-0 bg-white dark:bg-gray-900">
      {/* header row */}
    </thead>
    <tbody>{/* … */}</tbody>
  </table>
</div>

sticky top-0 on <thead> needs an explicit background — without one, body rows show through the header as they scroll past it — and it needs overflow-auto (not overflow-x-auto) on the wrapper so vertical scrolling is possible at all. position: sticky is scoped to its nearest scrolling ancestor, which is why the sticky header and the scroll container have to be this specific pair of elements, not just anywhere in the tree.

Zebra striping without adding a border per row

LicenseSummary.tsx separates rows with border-b plus a last:border-0 escape hatch. The alternative most table designs reach for instead is alternating row backgrounds, and Tailwind's even:/odd: variants make it a one-class change with no per-row exception to remember:

<tbody>
  {ROWS.map((r) => (
    <tr key={r.use} className="even:bg-gray-50 dark:even:bg-gray-800/50">
      {/* cells */}
    </tr>
  ))}
</tbody>

even: reads the row's position in its parent automatically, so there's no equivalent of the last:border-0 fix-up this table needed for borders — the pattern doesn't have an edge case at the boundary. The trade is visual, not technical: borders read as more precise for a table people scan cell-by-cell (a licence grid, a numeric report), stripes read as easier to track row-by-row across many columns. Either is a single Tailwind variant; picking between them is a design call, not an accessibility one — screen readers don't perceive either.

Mistakes and how they show up

MistakeHow it shows upFix
overflow-x-auto on the table, no min-wColumns compress and text wraps instead of scrollingPut a min-w-[…] on the <table>, overflow-x-auto on its wrapper
No <caption>Screen-reader table navigation has no context for what the table containsAdd a <caption>, sr-only if a visible heading already covers it
Icon-only cells (✓/✗, colored dots) with no textMeaning conveyed by color/glyph alone is unreliable for screen readersPair the visual mark with sr-only text
Missing scope="col" / scope="row"Cell-to-header relationship is ambiguous outside the visual gridAlways set scope on <th> elements
border on every <tr> including the lastAn extra bottom border/double border at the table's edgelast:border-0 on the row, or divide-y on <tbody> instead of per-row borders
Reaching for a table library before checking table complexityShips sort/filter/virtualization code nobody asked forA static table is plain HTML; see when tables need virtualization for when the complexity is actually earned

Frequently asked questions

Do I need border-collapse for a Tailwind table? Not if you use border-b per row and last:border-0 on the final one, as this codebase does — that avoids the double-border artifact border-collapse: collapse exists to fix, without adding a CSS property outside the utility system.

How do I make a Tailwind table responsive without a library? Wrap it in a div with overflow-x-auto, give the <table> a min-w-[…], and let it scroll. That's the whole pattern for a table where every column matters; see the comparison table above for when hiding columns or reflowing to cards is the better call instead.

Should checkmarks be text or icons? Either is fine visually — the requirement is a text alternative either way. An icon component (from lucide-react, already a dependency here) needs the same aria-hidden + sr-only pairing a raw Unicode glyph does; neither is accessible by itself.

When should I reach for a table library instead of a plain <table>? When the table needs interactive state — sort, filter, pagination, or row virtualization for a large dataset — a library or hand-rolled state management earns its cost. The data-table virtualization post covers exactly that tradeoff and when the plain-HTML answer stops being enough.

Templates with tables at this scale

ASoc Vertex centers on a single dense eCommerce dashboard with a top-selling-products table showing live stock status. ASoc Crest ships 8 app modules — customers, products, orders, invoices — each backed by its own table view inside a full component library. ASoc Estate runs property list, grid and detail tables across its listings module.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates.

Keep reading

Tutorial9 min read

Tailwind Design Tokens: 41 Declared, 22 Values That Went Around Them

41 tokens across three of Tailwind v4's 19 namespaces, and 161 utilities that spell a value literally instead. 22 of those literals were already a token in the same stylesheet.

Read more