CSS Sticky Table Header

A table header that stays fixed while scrolling using position: sticky on <thead>.

Published May 20, 2026 Beginner 7 min read

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
North1,2041,3881,1021,5401,6111,489
South9821,0411,2201,1551,3091,402
East1,4551,3901,5021,4771,5201,610
West7608129059881,0241,110
Central1,1011,1401,0881,2051,2601,318
Islands340366402388410455

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.

ItemQtyAmount
Keyboard2180.00
Monitor stand164.50
Cable set428.00
Desk mat139.00
Webcam1112.00
Headset2210.00
Dock1149.00
Laptop riser387.00
Total15869.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

position
sticky keeps 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, bottom or left, 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-y
auto on the wrapper is what creates the scroll container. Any closer ancestor with an overflow value 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-shadow
inset 0 -1px 0 replaces the header's bottom border, which a collapsed border model would otherwise scroll away.

Browser support

FeatureChromeFirefoxSafariEdge
position: sticky91597.191
box-shadow43.5512
transition455.112
border-radius433.112

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 top offset. position: sticky with no offset behaves like position: relative and 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-bottom with border-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?
Put the table in a wrapper with 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?
Check for an ancestor with 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?
In current browsers, yes. Support for sticky positioning on table elements landed much later than on ordinary boxes, which is why Can I Use records full support in Chrome and Edge only from 91. Sticking the cells works across a wider range of versions and looks identical.
How do I keep the border under a sticky header?
Swap 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?
Yes. Give the header cells 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.