CSS Sticky Table Header
A table header that stays fixed while scrolling using position: sticky on <thead>.
A CSS sticky table header keeps the column labels visible while the rows scroll underneath them. position: sticky with top: 0 on each <th> is the whole mechanism. The cell behaves normally until the scroll position would push it off the top of its container, at which point it stops and stays there until the table itself scrolls out of view.
Three things have to be true or nothing sticks: the header needs an offset, something above it has to be scrolling, and the sticky cell needs an opaque background so the rows passing under it do not show through. The variants below extend the same idea to a first column that stays put during horizontal scrolling, and to a totals row pinned to the bottom edge.
Scroll the rows
| Name | Role | Status | Joined |
|---|---|---|---|
| Alice Chen | Designer | Active | Jan 2023 |
| Bob Torres | Engineer | Active | Mar 2023 |
| Carol Kim | Manager | Pending | Jun 2023 |
| David Park | Engineer | Inactive | Aug 2022 |
| Eva Müller | Designer | Active | Nov 2023 |
| Frank Liu | Analyst | Active | Feb 2024 |
| Grace Obi | Engineer | Pending | Apr 2024 |
| Henry Walsh | Manager | Inactive | Sep 2022 |
HTML
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th>Joined</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice Chen</td>
<td>Designer</td>
<td><span class="badge-status badge-active">Active</span></td>
<td>Jan 2023</td>
</tr>
<tr>
<td>Bob Torres</td>
<td>Engineer</td>
<td><span class="badge-status badge-active">Active</span></td>
<td>Mar 2023</td>
</tr>
<tr>
<td>Carol Kim</td>
<td>Manager</td>
<td><span class="badge-status badge-pending">Pending</span></td>
<td>Jun 2023</td>
</tr>
<tr>
<td>David Park</td>
<td>Engineer</td>
<td><span class="badge-status badge-inactive">Inactive</span></td>
<td>Aug 2022</td>
</tr>
<tr>
<td>Eva Müller</td>
<td>Designer</td>
<td><span class="badge-status badge-active">Active</span></td>
<td>Nov 2023</td>
</tr>
<tr>
<td>Frank Liu</td>
<td>Analyst</td>
<td><span class="badge-status badge-active">Active</span></td>
<td>Feb 2024</td>
</tr>
<tr>
<td>Grace Obi</td>
<td>Engineer</td>
<td><span class="badge-status badge-pending">Pending</span></td>
<td>Apr 2024</td>
</tr>
<tr>
<td>Henry Walsh</td>
<td>Manager</td>
<td><span class="badge-status badge-inactive">Inactive</span></td>
<td>Sep 2022</td>
</tr>
</tbody>
</table>
</div>
CSS
.table-scroll {
max-height: 240px;
overflow-y: auto;
border: 1px solid #2a2a2d;
border-radius: 12px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
thead th {
position: sticky;
top: 0;
z-index: 1; /* keeps the header above rows scrolling under it */
background: #141415;
padding: .75rem 1rem;
text-align: left;
font-weight: 600;
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .06em;
color: #88888f;
border-bottom: 1px solid #2a2a2d;
}
tbody tr {
border-bottom: 1px solid #2a2a2d;
transition: background .15s;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: #1c1c1e;
}
tbody td {
padding: .65rem 1rem;
color: #f0f0f0;
}
tbody td:first-child {
font-weight: 500;
}
.badge-status {
display: inline-block;
padding: .2rem .6rem;
border-radius: 99px;
font-size: .75rem;
font-weight: 600;
}
.badge-active {
background: rgba(87, 217, 163, .12);
color: #57d9a3;
}
.badge-pending {
background: rgba(255, 212, 68, .12);
color: #ffd444;
}
.badge-inactive {
background: #1c1c1e;
color: #88888f;
}
Other ways to build it
Sticky header and sticky first column
Scroll this one sideways as well as down. The header cells stick to the top, the row label cells stick to the left, and the cell where the two meet has to do both. That corner cell also needs the highest z-index of the three, because it has to win against the header traveling horizontally past it and against the column traveling vertically past it. Each pinned cell carries its own opaque background, since a transparent one would let the data slide visibly underneath.
| Region | Jan | Feb | Mar | Apr | May | Jun |
|---|---|---|---|---|---|---|
| North | 1,204 | 1,388 | 1,102 | 1,540 | 1,611 | 1,489 |
| South | 982 | 1,041 | 1,220 | 1,155 | 1,309 | 1,402 |
| East | 1,455 | 1,390 | 1,502 | 1,477 | 1,520 | 1,610 |
| West | 760 | 812 | 905 | 988 | 1,024 | 1,110 |
| Central | 1,101 | 1,140 | 1,088 | 1,205 | 1,260 | 1,318 |
| Islands | 340 | 366 | 402 | 388 | 410 | 455 |
HTML
<div class="table-scroll-2d">
<table class="table-2d">
<thead>
<tr><th scope="col">Region</th><th scope="col">Jan</th></tr>
</thead>
<tbody>
<tr><th scope="row">North</th><td>1,204</td></tr>
</tbody>
</table>
</div>
CSS
/* both axes scroll, so both axes can pin */
.table-scroll-2d {
max-height: 240px;
overflow: auto;
}
/* wider than the container, or there is nothing to scroll sideways */
.table-2d {
min-width: 980px;
border-collapse: collapse;
}
.table-2d thead th {
position: sticky;
top: 0;
z-index: 2;
background: #141415;
/* a collapsed border belongs to the table and scrolls away,
an inset shadow belongs to the cell and stays put */
box-shadow: inset 0 -1px 0 #2a2a2d;
}
.table-2d tbody th {
position: sticky;
left: 0;
z-index: 1;
background: #141415;
text-align: left;
}
/* the corner does both, so it has to outrank both */
.table-2d thead th:first-child {
left: 0;
z-index: 3;
}
Totals pinned to the bottom
The same property with bottom: 0 instead of top: 0 holds a row against the lower edge of the container until the table has finished scrolling past it. A <tfoot> is the right element for a summary row, and browsers no longer require it to be written before the body. The border problem inverts here: the line that matters is above the row rather than below it, so the inset shadow flips to inset 0 1px 0.
| Item | Qty | Amount |
|---|---|---|
| Keyboard | 2 | 180.00 |
| Monitor stand | 1 | 64.50 |
| Cable set | 4 | 28.00 |
| Desk mat | 1 | 39.00 |
| Webcam | 1 | 112.00 |
| Headset | 2 | 210.00 |
| Dock | 1 | 149.00 |
| Laptop riser | 3 | 87.00 |
| Total | 15 | 869.50 |
HTML
<table class="table-foot">
<thead>...</thead>
<tbody>...</tbody>
<tfoot>
<tr><td>Total</td><td>15</td><td>869.50</td></tr>
</tfoot>
</table>
CSS
.table-foot tfoot td {
position: sticky;
bottom: 0;
z-index: 1;
background: #1c1c1e;
font-weight: 600;
/* the line that matters is above this row, not below it */
box-shadow: inset 0 1px 0 #2a2a2d;
}
How it works
position: sticky on <th> elements inside <thead> makes the header row stick to the top of its scrolling container. The top: 0 offset anchors it. A z-index is needed so the sticky header covers body rows as they scroll under it. The table must be inside a container with overflow-y: auto and a defined max-height for scrolling to engage.
The rule targets thead th rather than thead for a reason that is entirely historical. Sticky positioning on the table specific elements, thead, tbody and tr, arrived long after it worked on ordinary boxes, which is why Can I Use only records full support in Chrome and Edge from version 91. Sticking the individual cells has worked far longer and still produces the same visible result, so it remains the safer selector.
A sticky element sticks inside its nearest scrolling ancestor, and that ancestor is decided by overflow, not by intent. If any wrapper between the table and the viewport has overflow: hidden, that wrapper becomes the scroll container, and since it never scrolls the header never sticks. This is the most common cause of a sticky header that silently does nothing, and it is invisible in the CSS of the element itself.
The offset is not optional. position: sticky with no top, right, bottom or left behaves exactly like position: relative, because there is no threshold for it to stop at. top: 0 pins the cell to the top edge of the scroll container, and a larger value leaves a gap, which is what you use when a site header is already occupying the first sixty pixels of the viewport.
z-index is needed because the browser paints in document order and the body rows come after the header. Without it a sticky header stays in place and gets drawn underneath every row that passes it. Any positive value works when nothing else on the page is competing, and the sticky cells in the two axis variant need two different values so the corner cell wins against both.
border-collapse: collapse and sticky headers do not get along. Collapsed borders belong to the table rather than to the cell, so they scroll away with the table while the cell stays pinned, and the line under the header disappears the moment it sticks. Replacing border-bottom with box-shadow: inset 0 -1px 0 puts the line back, because a shadow is painted by the cell itself.
CSS properties used
positionstickykeeps an element in normal flow until a scroll threshold is reached, then holds it in place until its container scrolls past.top- The threshold. Without one of
top,right,bottomorleft, a sticky element behaves like a relative one and never sticks. z-index- Lifts the pinned cell above the rows that come after it in the document, which is what stops rows painting over the header.
overflow-yautoon the wrapper is what creates the scroll container. Any closer ancestor with anoverflowvalue takes that job instead.max-height- Gives the scroll container something to overflow. A container tall enough for every row never scrolls, so the header never sticks.
box-shadowinset 0 -1px 0replaces the header's bottom border, which a collapsed border model would otherwise scroll away.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
position: sticky | 91 | 59 | 7.1 | 91 |
box-shadow | 4 | 3.5 | 5 | 12 |
transition | 4 | 5 | 5.1 | 12 |
border-radius | 4 | 3 | 3.1 | 12 |
The Chrome and Edge figure of 91 looks far later than it should for a property that has been usable for years, and the gap is specifically about the table elements. Sticky positioning on a <th> predates it comfortably, which is why every example on this page sticks cells rather than rows. Where position: sticky is not supported at all the header scrolls away like any other row, so the table stays readable and only loses the pinning.
Accessibility notes
A sticky header improves comprehension for everyone reading a long table, and it does it without changing the markup, so screen reader users still get the same <th> associations they always did. Keep the semantics: scope="col" on the header cells is what tells assistive technology which column a value belongs to, and no amount of visual pinning substitutes for it.
The pinned row eats vertical space. On a phone in landscape, a two line header inside a 240 pixel scroll box leaves very little room for actual data, and at a 200 percent browser zoom it can leave none. Setting the container height in rem rather than pixels lets it grow with the text, and dropping the sticky behavior under a short viewport media query is a reasonable fallback.
Keyboard users scroll the container with the arrow keys once it has focus, which works because the wrapper is a scroll container. Give that wrapper tabindex="0" and an accessible name so it is reachable and announced, since a scrollable region with no name is easy to land in and hard to identify.
What you can build with it
- Admin data tables. Long lists of users, orders or records where the column meanings stop being obvious after the first screenful.
- Pricing comparisons. Plan names pinned at the top while a reader scrolls through dozens of feature rows.
- Financial statements. A sticky first column of line item labels alongside a sticky header of periods, which is the two axis variant below.
- Schedules and timetables. Times down the first column, days across the header, both pinned so a cell in the middle can be read without scrolling back.
- Dashboards. A table inside a fixed height card, where the header stays put and a totals row stays pinned to the bottom edge.
Mistakes worth avoiding
- An ancestor with
overflow: hidden. It quietly becomes the scroll container and the header never sticks, with nothing in the sticky element's own rules to hint at why. - Forgetting the
topoffset.position: stickywith no offset behaves likeposition: relativeand simply scrolls away. - Leaving the sticky cell transparent. Rows pass underneath and show through the header, which looks like a rendering fault.
- Skipping
z-index. The header stays pinned in the right place and gets painted under every row that reaches it. - Relying on
border-bottomwithborder-collapse: collapse. The line belongs to the table, scrolls away with it, and the pinned header ends up with no edge at all.
Frequently asked questions
How do you make a table header sticky in CSS?
max-height and overflow-y: auto, then give thead th the rules position: sticky, top: 0, a z-index of at least 1, and an opaque background. All four are needed. Missing any one of them produces a header that either does not stick or is unreadable when it does.Why is my sticky header not working?
overflow: hidden, auto or scroll between the table and the viewport, because the nearest one of those becomes the scroll container. Then confirm a top offset is set, and that the container is actually shorter than its content. A container tall enough to show every row never scrolls, so nothing ever sticks.Can position: sticky go on thead instead of th?
How do I keep the border under a sticky header?
border-bottom for box-shadow: inset 0 -1px 0 #2a2a2d. Under border-collapse: collapse the border belongs to the table and scrolls away with it, while a shadow is painted by the cell and travels with it.Can you make both a header and a first column sticky?
top: 0 and the first column cells left: 0, then give the cell where they meet both offsets and a higher z-index than either. The first variant below does this, and it needs the table to overflow horizontally as well as vertically.