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.
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.
| Approach | What happens below the breakpoint | Headers | Best for |
|---|---|---|---|
overflow-x-auto wrapper + min-w table (used here) | Horizontal scroll, layout unchanged | Stay attached to columns | Tables where every column matters at every width |
Hide columns with hidden md:table-cell | Narrow columns disappear entirely | Only visible ones shown | Tables with a clear primary/secondary column split |
Reflow to stacked cards (grid + per-row labels) | Each row becomes a labeled card | Repeated as a label per field | Tables with few rows and many fields per row |
| Data-table library (TanStack Table, AG Grid) | Depends on the library's own responsive mode | Managed by the library | Tables 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
| Mistake | How it shows up | Fix |
|---|---|---|
overflow-x-auto on the table, no min-w | Columns compress and text wraps instead of scrolling | Put 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 contains | Add a <caption>, sr-only if a visible heading already covers it |
| Icon-only cells (✓/✗, colored dots) with no text | Meaning conveyed by color/glyph alone is unreliable for screen readers | Pair the visual mark with sr-only text |
Missing scope="col" / scope="row" | Cell-to-header relationship is ambiguous outside the visual grid | Always set scope on <th> elements |
border on every <tr> including the last | An extra bottom border/double border at the table's edge | last:border-0 on the row, or divide-y on <tbody> instead of per-row borders |
| Reaching for a table library before checking table complexity | Ships sort/filter/virtualization code nobody asked for | A 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.
