Scrollable Tables / Fixed Table Headers

I don't normally write about CSS because it's not my specialist area, but whenever I want to create a table with fixed headers, I keep having to re-discover the same techniques and pitfalls, so I thought I'd write it up — both to be useful to others and, frankly, for me to come back to the next time I need to do it!

Below is an example of what I mean by a table with fixed headers. Notice that when you scroll the table, the headers stay put and only the data content scrolls:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Now we know what we're doing, how do we do it?

Back in the day, you had to rely on JavaScript to get this kind of effect, but thankfully CSS has moved on and there's a much better way now. However, it's not quite as simple as people tend to suggest. In this post, I'll walk through everything that's needed and point out a couple of pitfalls along the way.

A couple of notes before we start:

In this post, I'll wrap the example tables in a div styled as follows so they're set off from the main content and don't take up too much space.

.example-wrapper {
    height: 10rem;
    overflow: auto;
    padding: 0;
    border: 2px solid #fb0;
    margin-block-end: 24px;
}
.example-wrapper > * {
    width: 100%;
}

Also, all of the examples use the same HTML structure:

<div class="example-wrapper">
    <table class="sticky">
        <thead>
            <tr>
                <th>Year</th>
                <th>Best Actress</th>
                <th>Best Actor</th>
            </tr>
        </thead>
        <tbody>
            <!-- ...data rows... -->
        </tbody>
    </table>
</div>

Okay, let's go!

It's just position: sticky, right?

If you search for how to do this, you'll find people saying (effectively) "Oh, you just put position: sticky on the th cells" without going into any more detail (even though their examples actually do more than that).

Okay. If it's that simple, let's see what happens if we only do that:

.sticky th {
    position: sticky;
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

So...that doesn't work.

Where to stick to

It doesn't work because unless you tell the headers where to stick to, they won't actually be sticky. That requires a top, bottom, right, or left property. Since we want ours to stick to the top, let's add top: 0:

.sticky th {
    position: sticky;
    top: 0; /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Well, the headers do stick...

More to do

...but:

  1. When you scroll, the text of the td cells shows up behind the headers
  2. The headers move up slightly when you start scrolling

The first makes perfect sense in retrospect and we should have expected it. Our th cells have no background properties set, so they default to being transparent, and the data cells are indeed scrolling up behind them. Sadly, as far as I know, the only way to fix it is to set the background color on the th cells to match the background behind the table — the kind of redundancy I hate (though CSS custom properties aka "CSS variables" help).

The slight jump when we start scrolling seems odd, though. The headers are at the top of the table, right? So they should be zero pixels offset from the table...except that by default, tables have some cell spacing both between the cells and between the outer edges of the cells and the table edges. On close inspection, we can see that the headers are actually 2px from the top of the table (in the browsers I've checked). Let's look at how we can deal with that:

One option would be to use border-collapse: collapse, which would collapse the spacing between the top edge of the headers and the top edge of the table. It would work, but it has an unfortunate side-effect we'll come back to in a minute, so let's put it aside for now.

The other option is to remove the spacing entirely, without collapsing borders. In modern CSS, we can do that with the border-spacing property, which takes over from the old deprecated cellspacing attribute on table elements (and unlike that attribute, has the ability to set vertical and horizontal spacing to different values). So if we explicitly set our border-spacing to 0 so it matches the top on our th cells, we should get rid of the jump. (It's more redundancy, sadly.) Note that if you don't use 0 (at least for the vertical spacing), you'll get the td cell content showing up in the space you leave, since that space isn't inside the th cells so it doesn't get covered by their background. If you want spacing around the cells, you can use padding or borders set to the background color.

Let's see the fixes in action:

.sticky {
    border-spacing: 0;          /* <=== */
}
.sticky th {
    position: sticky;
    top: 0;
    background-color: #15314f;  /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Not bad! Let's throw a bit more styling at that, just for cosmetics:

.sticky {
    border-spacing: 0;
    line-height: 1;                 /* <=== */
}
.sticky th,
.sticky td {
    padding: 8px;                   /* <=== */
}
.sticky th {
    position: sticky;
    top: 0px;
    background-color: #15314f;
    border-bottom: 2px solid #fb0;  /* <=== */
    text-align: left;               /* <=== */
    font-weight: normal;            /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Looking good!

The table border

I should note that the border around the table in the example above is supplied by the wrapper div I'm using to contain the tables in this post, not the table element. Let's see what happens if we put it on the table element instead:

.example-wrapper {
    height: 10rem;
    overflow: auto;
    padding: 0;
    /* border: 2px solid #fb0; <=== No border on the wrapper */
}
.sticky {
    border-spacing: 0;
    line-height: 1;
    border: 2px solid #fb0;     /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

You see the problem — since the bottom of the table is initially out of sight, so is its border; and when the table is scrolled up, its top border scrolls out of view! (And our headers are moving slightly when scrolling again, but we could fix that with top.) For me, a wrapper div is the way to go if you want a border around a scrollable table.

Using border-collapse: collapse

Earlier I mentioned that border-collapse: collapse has an unfortunate side effect. Let's see what it is:

.sticky {
    /* No border-spacing */
    line-height: 1;
    border-collapse: collapse;  /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Hey, why did the th borders scroll away?!

It's because when the borders are collapsed, the border at the bottom of the th cells is "shared" with the border at the top of the td cells underneath them. From the CSS2 spec on the collapsing border model:

Borders are centered on the grid lines between the cells.

Everything scrolls except the th cells, including the shared borders. So sticking with separate borders controlled by border-spacing is probably the way to go. That said, there are workarounds if you need border-collapse: collapse (more in this question's answers on Stack Overflow):

One is to use a box-shadow instead of a border, since box-shadow — not being a border — doesn't participate in border collapse:

.sticky {
    line-height: 1;
    border-collapse: collapse;
}
.sticky th,
.sticky td {
    padding: 8px;
}
.sticky th {
    text-align: left;
    position: sticky;
    top: 0px;
    background-color: #15314f;
    font-weight: normal;
    box-shadow: inset 0 -2px 0 #fb0; /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Another is to put the border on the ::after pseudo-element instead; its border doesn't get collapsed, and it stays with the th:

.sticky {
    line-height: 1;
    border-collapse: collapse;
}
.sticky th,
.sticky td {
    padding: 8px;
}
.sticky th {
    text-align: left;
    position: sticky;
    top: 0px;
    background-color: #15314f;
    font-weight: normal;
}
.sticky th::after {                     /* <=== */
    content: '';                        /* <=== */
    position: absolute;                 /* <=== */
    left: 0;                            /* <=== */
    right: 0;                           /* <=== */
    bottom: 0;                          /* <=== */
    border-bottom: 2px solid #fb0;      /* <=== */
}

Result:

YearBest ActressBest Actor
2023Michelle YeohBrendan Fraser
2022Jessica ChastainWill Smith
2021Frances McDormandAnthony Hopkins
2020Renée ZellwegerJoaquin Phoenix
2019Olivia ColmanRami Malek
2018Frances McDormandGary Oldman
2017Emma StoneCasey Affleck
2016Brie LarsonLeonardo DiCaprio
2015Julianne MooreEddie Redmayne
2014Cate BlanchettMatthew McConaughey
2013Jennifer LawrenceDaniel Day-Lewis
2012Meryl StreepJean Dujardin
2011Natalie PortmanColin Firth
2010Sandra BullockJeff Bridges
2009Kate WinsletSean Penn
2008Marion CotillardDaniel Day-Lewis
2007Helen MirrenForest Whitaker
2006Reese WitherspoonPhilip Seymour Hoffman
2005Hilary SwankJamie Foxx
2004Charlize TheronSean Penn
2003Nicole KidmanAdrien Brody
2002Halle BerryDenzel Washington
2001Julia RobertsRussell Crowe
2000Hilary SwankKevin Spacey

Margin collapse

Another thing you may have to watch out for (as always!) is margin collapse creating space at the top of the table that isn't covered by the th cells and their background where the td content can show through. I've seen this only when I have my OS font size set to "large" and have my browser set to use a default font size of 20 instead of 16 (I assume they mean pixels, they don't actually say), and even then, it isn't reliable. Just something to keep in mind if you're getting td content showing above the headers.

Wrapup

So there you have it. Basically:

  • Use position: sticky and a top value on the th cells
  • Give the th cells a background so the td content doesn't show through
  • Either:

    • Set border-spacing and top both to 0 and don't use border-collapse: collapse on the table
      or
    • Do set border-collapse: collapse on the table and don't put a border on your th cells (or do it using one of the work-arounds shown above)
  • Use a wrapper div if you want a border around the table
  • As always, beware margin collapse

Happy coding!

Have a question or comment about this post? Ping me on Mastodon at @tjcrowdertech@hachyderm.io!