Tables have a way of blowing out layouts. A dozen columns, a hundred rows, a narrow viewport — and suddenly you have a horizontal scrollbar that disconnects the header from the data. The fix is straightforward in principle: make the table container scroll while the <thead> stays pinned to the top. In practice there are two or three small traps that will quietly break the effect if you walk into them blind.
This guide covers the complete pattern using Tailwind CSS utility classes. The utilities themselves are the same in Tailwind v3 and v4 (sticky positioning and overflow are part of the core, not the configuration layer), so the code works in both versions without adjustment.
How the pattern works at the CSS level
Two independent CSS properties combine to make this work:
- Overflow on the container —
overflow: autolets the wrapper scroll when its content is wider or taller than it is. The table overflows inside this box rather than breaking the page layout. - Sticky positioning on the thead —
position: sticky; top: 0pins the header to the top of its nearest scrolling ancestor. Critically, that scrolling ancestor is the same overflowing container, not the viewport.
This last point is the most common source of confusion. A sticky element sticks within the scrollport created by its overflowing ancestor. If overflow-auto and sticky top-0 are on the wrong elements relative to each other, the header will not stick.
The basic structure
Start with a wrapper <div> that caps the height and enables vertical scrolling. The <table> lives inside it, and the <thead> gets sticky positioning with an explicit stacking context and an opaque background.
<!-- Scrollable container -->
<div class="relative max-h-96 overflow-auto rounded-lg border border-gray-200">
<table class="w-full border-separate border-spacing-0 text-sm text-left">
<!-- Sticky header -->
<thead class="sticky top-0 z-10 bg-white">
<tr>
<th class="border-b border-gray-200 px-4 py-3 font-semibold text-gray-700">Name</th>
<th class="border-b border-gray-200 px-4 py-3 font-semibold text-gray-700">Role</th>
<th class="border-b border-gray-200 px-4 py-3 font-semibold text-gray-700">Status</th>
<th class="border-b border-gray-200 px-4 py-3 font-semibold text-gray-700">Joined</th>
</tr>
</thead>
<!-- Table body -->
<tbody class="divide-y divide-gray-100">
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-900">Alice Martin</td>
<td class="px-4 py-3 text-gray-600">Engineer</td>
<td class="px-4 py-3"><span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Active</span></td>
<td class="px-4 py-3 text-gray-600">Jan 2024</td>
</tr>
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-900">Ben Torres</td>
<td class="px-4 py-3 text-gray-600">Designer</td>
<td class="px-4 py-3"><span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Active</span></td>
<td class="px-4 py-3 text-gray-600">Mar 2024</td>
</tr>
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-gray-900">Carla Webb</td>
<td class="px-4 py-3 text-gray-600">Manager</td>
<td class="px-4 py-3"><span class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700">Away</span></td>
<td class="px-4 py-3 text-gray-600">Sep 2023</td>
</tr>
<!-- ...more rows -->
</tbody>
</table>
</div>
Why each class is there
On the wrapper div
max-h-96— caps the container at 24rem. Without a fixed height or max-height, the container grows to fit all rows and there is nothing to scroll.overflow-auto— adds scrollbars only when needed. You could also useoverflow-y-autoif you want the table to only scroll vertically, oroverflow-autoto handle both axes when columns overflow horizontally too.relative— gives thez-indexon thetheada containing stacking context to work within.rounded-lg border border-gray-200— purely cosmetic, gives the table a contained card-like appearance.
On the table element
border-separate border-spacing-0— this pair is important once you add borders to individual cells. With Tailwind's defaultborder-collapse, sticky headers cause borders to disappear or jump as you scroll because the browser merges adjacent cell borders and the merged border does not travel with the sticky element. Settingborder-separatewithborder-spacing-0keeps borders on individual cells (where they scroll with the sticky header correctly) while eliminating the gap thatborder-separatewould otherwise create between cells.
On the thead
sticky top-0— positions the header so it sticks to the top of the scrolling container once the user scrolls past it.z-10— ensures the header renders above the table body rows as they scroll beneath it. Without this, row content can bleed through the header.bg-white— the header must have an opaque background. A transparenttheadwill show the rows scrolling through it, which looks broken. Match this to whatever background color your design uses.
The border disappearing problem
The most common bug people hit is that the bottom border on the header row vanishes when it becomes sticky. This happens because border-collapse: collapse (the browser default, and Tailwind's border-collapse class) merges the header's bottom border with the first body row's top border. When rows scroll under the sticky header, the merged border travels with the rows, not the header — and the header appears border-less.
The fix described above — border-separate border-spacing-0 on the table plus border-b on each <th> — is the cleanest solution. Each cell owns its own border, the bottom border on the header cells stays attached to the thead, and it scrolls with it correctly.
An alternative that avoids touching border-separate is to use a box-shadow instead of a border-b on the header. Box-shadows are not affected by border-collapse and render correctly on sticky elements. In Tailwind you can do this with the shadow-sm class on the thead, or with an arbitrary value like shadow-[0_1px_0_0_theme(colors.gray.200)] if you need an exact match to your border color.
Handling both horizontal and vertical scrolling
If your table has many columns, you will want it to scroll horizontally as well. The same wrapper handles both — overflow-auto enables scrolling in whichever axis the content overflows. The sticky top-0 on the header continues to work: it sticks vertically within the scrollport, and the entire header (all columns) scrolls left and right together with the table body.
If you want to additionally pin the first column (for example, a "Name" column) while the rest scroll horizontally, apply sticky left-0 z-20 bg-white to both the <th> and the corresponding <td> in every body row. The z-20 (higher than the header's z-10) ensures the pinned cell sits on top of both the scrolling rows and the header when the two overlap at the top-left corner.
<!-- First-column pin added to header cell -->
<th class="sticky left-0 z-20 bg-white border-b border-r border-gray-200 px-4 py-3 font-semibold text-gray-700">
Name
</th>
<!-- First-column pin added to every body cell -->
<td class="sticky left-0 z-10 bg-white border-r border-gray-200 px-4 py-3 text-gray-900">
Alice Martin
</td>
Rounding the corners without breaking sticky
There is a known conflict: if you add overflow-hidden to the wrapper for rounded corners, it creates a new stacking context that can clip the sticky header. The pattern shown above uses rounded-lg on the wrapper alongside overflow-auto, which works in modern browsers (Chromium, Firefox, Safari) because they handle the combination correctly. If you see clipping in a specific browser, the reliable fallback is to drop overflow-hidden and use an outer container with rounded-lg overflow-hidden wrapping the scroll container — the outer layer clips for aesthetics while the inner layer scrolls.
Adapting the max height
Tailwind's height scale gives you several options beyond max-h-96:
max-h-64— 16rem, a compact table in a sidebarmax-h-screenormax-h-dvh— full viewport heightmax-h-[32rem]— arbitrary value when the standard scale does not fit
If the table lives inside a flex or grid layout where the parent has a fixed height, you can replace max-h-* with h-full overflow-auto and let the parent control the height instead.
Putting it all together
The complete minimal pattern is three things: a container with overflow-auto and a bounded height, a table with border-separate border-spacing-0, and a thead with sticky top-0 z-10 and an opaque background. Every other class in the example above is visual polish — spacing, colors, hover states — that you can swap for your own design tokens.
The pattern scales from a simple read-only data table to a full spreadsheet-style interface with pinned rows, pinned columns, and zebra striping. Each feature layers on cleanly because the foundation — one scrolling container, one sticky element — is solid from the start.