Tailwind Table Component: Responsive Data Tables with Overflow
Build responsive data tables in Tailwind CSS v4 — overflow scrolling, sticky headers, sortable columns, and dark mode. Real code, no fluff.
Tables Are Harder Than They Look
Honestly, tables are the most underestimated UI problem in frontend work. Every developer thinks they'll knock one out in twenty minutes, and then three hours later they're fighting with overflow on mobile, misaligned sticky headers, and a dark mode that makes the zebra stripes look like a crime scene.
The core tension is real: tables need to be wide enough to show all their data, but screens — especially on mobile — aren't. You can't just hide columns. You can't always paginate. Sometimes the data is the data, and you need to show all of it without making your layout explode.
This guide walks through building a production-quality Tailwind table component that handles horizontal overflow, sticky first columns, sortable headers, and dark mode. We're using Tailwind v4.0.2 throughout. If you're still on v3, most of this applies, but a few utility names have shifted — check the Tailwind v4 features overview for the diff.
The Overflow Wrapper Pattern
The fundamental fix for wide tables on narrow screens is an overflow wrapper — a container that clips horizontally and lets users scroll within it, rather than breaking your page layout. In Tailwind that's overflow-x-auto on the wrapper div.
Don't put overflow-x-auto on the table itself. It doesn't work the way you'd expect because <table> has its own box model quirks. The wrapper approach is the one that actually behaves consistently across browsers.
Here's the base structure you'll reach for every time:
export function DataTable({ columns, rows }: DataTableProps) {
return (
<div className="w-full overflow-x-auto rounded-xl border border-white/10">
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/5">
{columns.map((col) => (
<th
key={col.key}
className="px-4 py-3 text-left font-medium text-zinc-400 whitespace-nowrap"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className="border-b border-white/5 transition-colors hover:bg-white/5"
>
{columns.map((col) => (
<td key={col.key} className="px-4 py-3 text-zinc-300">
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}The min-w-[640px] on the table is the key move. It tells the table it's allowed to be 640px wide even if the container is only 320px — the wrapper will scroll. Without it, the table tries to compress itself into the container and columns get crushed.
Sticky Headers and Frozen First Columns
Sticky headers are table UX that users actually notice when it's missing. As soon as a table is taller than the viewport, losing track of what column means what becomes a real friction point. Tailwind's sticky and top-0 utilities handle this — but you need to set a background color on the sticky element, otherwise rows scroll right through it and it looks broken.
Frozen first columns are trickier. You're stacking two stickiness axes: sticky left-0 on the first <td> and <th> cells. You'll also want a box shadow on the right edge to visually separate the frozen column from the scrolling content — something like shadow-[2px_0_8px_rgba(0,0,0,0.3)].
// Sticky header + frozen first column
<thead className="sticky top-0 z-10">
<tr className="bg-zinc-900 border-b border-white/10">
<th className="sticky left-0 z-20 bg-zinc-900 px-4 py-3 text-left
font-medium text-zinc-400 whitespace-nowrap
shadow-[2px_0_8px_rgba(0,0,0,0.4)]">
Name
</th>
{/* remaining headers */}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-b border-white/5 hover:bg-white/5">
<td className="sticky left-0 z-10 bg-zinc-900 px-4 py-3
text-zinc-200 whitespace-nowrap
shadow-[2px_0_8px_rgba(0,0,0,0.3)]
group-hover:bg-zinc-800">
{row.name}
</td>
{/* remaining cells */}
</tr>
))}
</tbody>Notice the z-20 on the header corner cell (where sticky header meets sticky column). Without it, the scrolling column header slides under the sticky header and you get a weird visual bleed. That one took me an embarrassing amount of time to figure out the first time.
Sortable Column Headers
Sortable columns are where tables go from static displays to actually useful tools. The pattern isn't complicated — you track a sortKey and sortDir in state, click handlers update them, and you sort the rows before rendering.
What trips people up is the visual affordance. Users need to know which column is sorted and in which direction. A chevron icon that flips direction does this well. You also want a subtle background change on the sorted column header so it reads as 'active'.
import { useState, useMemo } from 'react';
type SortDir = 'asc' | 'desc';
export function SortableTable({ columns, rows }: DataTableProps) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
const sorted = useMemo(() => {
if (!sortKey) return rows;
return [...rows].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [rows, sortKey, sortDir]);
function handleSort(key: string) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
}
return (
<div className="overflow-x-auto rounded-xl border border-white/10">
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/5">
{columns.map((col) => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={`px-4 py-3 text-left font-medium cursor-pointer
select-none whitespace-nowrap transition-colors
${
sortKey === col.key
? 'text-white bg-white/10'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-white/5'
}`}
>
<span className="inline-flex items-center gap-1.5">
{col.label}
{sortKey === col.key && (
<span className="text-xs">
{sortDir === 'asc' ? '↑' : '↓'}
</span>
)}
</span>
</th>
))}
</tr>
</thead>
{/* tbody unchanged from above */}
</table>
</div>
);
}The useMemo call is not optional if your rows array is large. Re-sorting on every render will cause noticeable jank at 500+ rows. Keep it memoized.
Dark Mode and Zebra Striping with Tailwind v4
Dark mode for tables is mostly about being deliberate with your background colors. The common failure mode is using bg-white and bg-gray-100 for alternating rows, then forgetting those are terrible in dark mode. Go with opacity-based backgrounds from the start and you won't have to maintain two separate color sets.
Zebra striping with Tailwind's even: and odd: variants works well but can clash with hover states if you're not careful. A cleaner approach: use odd:bg-white/[0.02] and even:bg-transparent, then hover:bg-white/5 on all rows. The hover state is visually dominant enough that it overrides without any specificity fights.
For dark mode toggling in a React app, the theme toggle with React pattern applies directly here — just make sure your table wrapper is inside the element that carries the dark class. If you're curious how the color system underneath all this works, the Tailwind OKLCH colors article explains why oklch()-based palettes render better at low opacity than the older hex-derived ones.
One thing that often gets missed: the border colors. border-white/10 works great on dark backgrounds. On light backgrounds you want border-black/10. Use dark:border-white/10 border-black/10 if you're supporting both, or commit to one mode and move on.
Handling Empty States and Loading Skeletons
What does your table look like when it has no data? If you haven't thought about this, the answer is probably 'a header row floating in space', which isn't great. Empty states deserve actual design attention — at minimum a centered message, ideally something that explains why there's no data and what the user can do about it.
Loading skeletons beat spinners for tables. A spinner tells users 'something is happening'. A skeleton table tells users 'here's roughly how many rows are coming and where the data will land'. That context makes the wait feel shorter.
function TableSkeleton({ cols = 5, rows = 6 }: { cols?: number; rows?: number }) {
return (
<div className="overflow-x-auto rounded-xl border border-white/10">
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="border-b border-white/10 bg-white/5">
{Array.from({ length: cols }).map((_, i) => (
<th key={i} className="px-4 py-3">
<div className="h-3 w-20 rounded bg-white/10 animate-pulse" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r} className="border-b border-white/5">
{Array.from({ length: cols }).map((_, c) => (
<td key={c} className="px-4 py-3">
<div
className="h-3 rounded bg-white/5 animate-pulse"
style={{ width: `${60 + ((c * r) % 4) * 15}%` }}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}The width variation on skeleton cells (60 + ((c * r) % 4) * 15) is a small trick to avoid the 'too uniform' look that screams 'fake data'. Real data has variable width content, so your skeleton should too.
Responsive Table Strategies Beyond Overflow
Horizontal overflow scroll is the right default for most data tables. But it's not the only option and it's not always the best one. When should you consider alternatives? When your table has fewer than 4-5 columns and you can afford to reflow it on mobile.
The card-stack pattern — where each row becomes a card on small screens — works well for tables with 3-5 columns where rows represent discrete entities (users, orders, products). You use CSS to hide the <thead> on mobile and add data-label attributes to cells, displaying them as pseudo-labels via ::before content. It's a bit verbose but it produces genuinely usable mobile layouts without any JS.
For more advanced responsive behavior at the component level, container queries let you scope these breakpoints to the table's container width rather than the viewport — which matters a lot when your table lives inside a sidebar or a modal that's narrower than the viewport. Tailwind's @container support in v4 makes this much cleaner than it used to be.
Column hiding on mobile is the pragmatic middle ground. You decide which columns are 'optional' and add hidden md:table-cell to them. The table still overflows on small screens, but the overflow distance is reduced and the most important data stays visible without scrolling. This pairs well with the component patterns guide where we look at building show/hide column toggles.
Putting It Together: A Production Table Component
A real table component needs more than what any single section above shows. It needs all of it together: the overflow wrapper, sortability, loading and empty states, and sensible defaults for column alignment (numbers right-aligned, text left-aligned, status badges centered).
The thing that separates a table component people actually reuse from one that gets copy-pasted and diverges into chaos is a well-typed Column definition. Put alignment, sortability, and custom cell renderers in the column config — not scattered through the render logic. Your future self debugging a table at 11pm will thank you.
For a glassmorphism-styled variant of this table, the techniques in glassmorphism advanced patterns apply directly — swap the solid bg-zinc-900 backgrounds for backdrop-blur-md bg-white/5 and add a border border-white/10 to the wrapper. The sticky column shadow becomes shadow-[2px_0_12px_rgba(0,0,0,0.5)] to show through the blur. It looks genuinely good for dashboard UIs.
Does all of this feel like a lot of work for a table? Maybe. But data tables are one of those components that show up in virtually every application that deals with structured data, and getting the base right once means you're not re-solving overflow bugs, dark mode regressions, and loading state gaps every time. Build it properly once.
FAQ
Tables have a layout algorithm that ignores overflow in certain contexts. The browser tries to make the table fit its content before considering overflow. Wrapping the table in a <div className='overflow-x-auto'> gives you a block-level container that the overflow rules apply to normally. Always use the wrapper pattern.
You need an explicit background color on your sticky <thead> or <tr> element. Without it, the element is transparent and rows scroll right through. In dark mode use bg-zinc-900 or whichever solid color matches your design. Opacity-based backgrounds like bg-white/5 won't fully prevent bleed-through.
Use odd:bg-white/[0.02] and even:bg-transparent for zebra stripes, then hover:bg-white/5 on all rows. Because hover: has higher specificity than odd: and even: when the element is actually hovered, this works without any custom CSS. Avoid using solid colors for stripes if you want reliable hover behavior in both light and dark modes.
Add sticky left-0 z-10 to each <td> and <th> in the first column, and give them an explicit background color. Add z-20 to the header corner cell (first <th> in <thead>) so it stacks above both the sticky header and the sticky column. Use shadow-[2px_0_8px_rgba(0,0,0,0.3)] on first-column cells to visually separate them from the scrolling area.
Add text-right to both the <th> and the corresponding <td> cells for that column. Mismatching alignment between header and cell is a common mistake — if the header is left-aligned and the cell is right-aligned, the column looks broken. Encode the alignment in your column config object so both always stay in sync.
Yes. In Tailwind v4, add @container to the overflow wrapper div, then use @md:table-cell instead of md:table-cell on cells you want to show/hide. This means the table responds to how wide its container is, not the viewport — which is the correct behavior when the table lives inside a sidebar, panel, or modal.