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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin Spacey |
Well, the headers do stick...
More to do
...but:
- When you scroll, the text of the
td
cells shows up behind the headers - 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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:
Year | Best Actress | Best Actor |
---|---|---|
2023 | Michelle Yeoh | Brendan Fraser |
2022 | Jessica Chastain | Will Smith |
2021 | Frances McDormand | Anthony Hopkins |
2020 | Renée Zellweger | Joaquin Phoenix |
2019 | Olivia Colman | Rami Malek |
2018 | Frances McDormand | Gary Oldman |
2017 | Emma Stone | Casey Affleck |
2016 | Brie Larson | Leonardo DiCaprio |
2015 | Julianne Moore | Eddie Redmayne |
2014 | Cate Blanchett | Matthew McConaughey |
2013 | Jennifer Lawrence | Daniel Day-Lewis |
2012 | Meryl Streep | Jean Dujardin |
2011 | Natalie Portman | Colin Firth |
2010 | Sandra Bullock | Jeff Bridges |
2009 | Kate Winslet | Sean Penn |
2008 | Marion Cotillard | Daniel Day-Lewis |
2007 | Helen Mirren | Forest Whitaker |
2006 | Reese Witherspoon | Philip Seymour Hoffman |
2005 | Hilary Swank | Jamie Foxx |
2004 | Charlize Theron | Sean Penn |
2003 | Nicole Kidman | Adrien Brody |
2002 | Halle Berry | Denzel Washington |
2001 | Julia Roberts | Russell Crowe |
2000 | Hilary Swank | Kevin 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 atop
value on theth
cells - Give the
th
cells a background so thetd
content doesn't show through -
Either:
- Set
border-spacing
andtop
both to0
and don't useborder-collapse: collapse
on the table
or - Do set
border-collapse: collapse
on the table and don't put a border on yourth
cells (or do it using one of the work-arounds shown above)
- Set
- 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!