A sticky table header and first column in 6 lines of CSS

A sticky table header and first column in 6 lines of CSS Set markodenic.tech as your preferred Google source

The community we kept looking for, until we just made it.

Join today!

Twenty columns of monthly numbers. The user scrolls down and forgets which metric each column is. They scroll right and forget which row they are on. So they scroll back, remember, scroll forward again, and lose it a second time.

Every spreadsheet froze the header row and the first column decades ago. In CSS it is one property, applied twice.

The fix

html
<div class="scroller">
  <table>
    <thead>
      <tr><th>Product</th><th>Jan</th><th>Feb</th>...</tr>
    </thead>
    <tbody>
      <tr><th scope="row">Starter plan</th><td>412</td><td>508</td>...</tr>
    </tbody>
  </table>
</div>
css
.scroller { overflow: auto; max-height: 70vh; }
table { border-collapse: separate; border-spacing: 0; }

thead th { position: sticky; top: 0; z-index: 2; background: #fff; }
tbody th { position: sticky; left: 0; z-index: 1; background: #fff; }
thead th:first-child { left: 0; z-index: 3; }

Scroll in either direction and the labels stay.

Product

Scroll the table down, then sideways.

The row labels are <th scope="row">, not <td>. That is what lets the sticky column be a single selector, and it is what tells a screen reader that “Starter plan” names the row rather than being another data point in it.

The four things that break it

No background. A sticky element is not opaque. Without a background color the rows scroll straight through your header and it turns into unreadable soup. This is the number one reason people conclude sticky “doesn’t work on tables”.

border-collapse: collapse. With collapsed borders the lines belong to the table, not to the cells, so they stay behind while the sticky cell moves and your header ends up with a line floating under it. Draw the lines with a shadow instead:

css
table { border-collapse: separate; border-spacing: 0; }
thead th { box-shadow: inset 0 -1px 0 #e5e5e5; }

An ancestor with overflow: hidden. Sticky positions against the nearest scrolling ancestor. If the card wrapping your table has overflow: hidden for rounded corners, the header now sticks to a box that never scrolls, which looks exactly like nothing happening. Walk up the tree when a sticky element refuses to stick.

The corner cell. The top-left cell is in both the sticky row and the sticky column, so it needs to sit above both. That is the entire job of the last line.

Page scroll or box scroll, pick one

The version above scrolls inside a box, which is what you want for a wide data table. One side effect worth knowing: hit the bottom of the table and the page starts scrolling underneath it. That is scroll chaining, and overscroll-behavior: contain on .scroller ends it.

If you would rather the whole page scroll and only the header stick, drop the wrapper and its overflow entirely. position: sticky on the th then works against the viewport. If your site has a fixed header of its own, offset it:

css
thead th { top: 4rem; }

Same trick as keeping a sticky header off your headings, from the other direction.

Freezing a second column

Sticky columns stack by pixel offset, so the second one has to know how wide the first is:

css
tbody th, thead th:first-child { left: 0; width: 12rem; }

tbody td:nth-child(2), thead th:nth-child(2) {
  position: sticky;
  left: 12rem;
  background: #fff;
  box-shadow: 1px 0 0 #e5e5e5;
}

Two frozen columns is usually the limit before you have eaten the screen on a laptop, and one is the limit on a phone. If the first column is not the identity of the row, freeze nothing.

The detail that sells it

A sticky column with no edge looks like it is glitching over the content. Give it one:

css
tbody th { box-shadow: 1px 0 0 #e5e5e5; }

Now it reads as a frozen panel rather than a rendering artifact.

Where this comes up

  • Reporting tables: metrics down the side, months across the top.
  • Pricing comparisons: feature names stay put while plans scroll.
  • Admin lists: the name column stays visible while you scroll to the actions column.
  • Timesheets and schedules: people down one axis, days across the other.

Why this matters

  • The scroll-back-and-forth is the whole problem: users are not confused by your data, they are confused by which column it is in.
  • Nothing is measured: no scrollTop listener, no cloned header row absolutely positioned on top of the real one, no width syncing between the clone and the original.
  • It is the same table: still sortable, selectable, copyable, printable, and readable to a screen reader, because you did not rebuild it out of divs.

Open your widest table and scroll right. If the row you are reading became anonymous, the fix is three selectors.

Happy coding!

Marko

Rate this issue

Your ratings shape which issues I recommend to you.