Fixed Table Headers

January 6, 2020 ; 9 Comments

position: sticky

<table>

<div>

Standard HTML tables are not that complex. But when developers are unfamiliar with HTML or let third-party libraries generate them, they are usually an inaccessible over-engineered mess. One of the more common reasons I hear developers reach for them is because they want fixed headers. For simple tables, that is mostly unnecessary.

The thing about position: sticky is that it only works in articles which explain how to use position: sticky . Rob Dodson (@rob_dodson) April 13, 2019

Everything in this post assumes a left-to-right, top-to-bottom reading order and language.

The CSS

For fixed column headers:

th { position: -webkit-sticky; position: sticky; top: 0; z-index: 2; }

For fixed row headers:

th[scope=row] { position: -webkit-sticky; position: sticky; left: 0; z-index: 1; }

If you plan to have both in one site (page, screen, whatever), then you may want to avoid conflicts with a more specific selector. After all, row headers will not work properly without scope="row" , but column headers do just fine without a scope (and in my experience are generally absent).

th:not([scope=row]) { position: -webkit-sticky; position: sticky; top: 0; z-index: 2; }

The different z-index values help ensure your column headers sit in front of your row headers. Otherwise the visible row header will look like it is for the column headers and that is just weird. While you are at it, make sure the header cells have a background color or you will get layered text.

For row headers, you may want to add a border on the right to make the clipping of adjacent cells a bit less weird. The problem is that CSS borders don’t work here. They just scroll away. A little trickery with a background gradient can solve that problem:

th[scope=row] { background: linear-gradient(90deg, transparent 0%, transparent calc(100% - .05em), #d6d6d6 calc(100% - .05em), #d6d6d6 100%); }

Examples

View this example at Codepen.

See the Pen Fixed Table Header Demo by Adrian Roselli (@aardrian) on CodePen.

Responsive Uses

Obviously this approach can work well for responsive tables. I have shown how to use a scrolling container. That means we will definitely have a wrapper container and it also means it is likely to scroll independent of the page.

Note my selector is intentionally convoluted. I want to ensure that any overflow styles won’t be applied unless the container has tabindex (for keyboard users), along with a region role and accessible name (for screen reader users). Feel free to adjust for your own uptightness and target browser support.

div[tabindex="0"][aria-labelledby][role="region"] { overflow: auto; }

Scrollbars are often hidden by default on mobile devices (and in some desktop browser configurations), so the visual affordance that the user needs to scroll is often gone. Using Lea Verou’s 2012 post Pure CSS scrolling shadows with background-attachment: local will add a bit of a shadow for the vertically-scrolling content, and Chen Hui Jing adapted it to a horizontal scroll. I tweaked them to use em s instead of px so they will scale better.

div[tabindex="0"][aria-labelledby][role="region"].rowheaders { background: linear-gradient(to right, transparent 30%, rgba(255,255,255,0)), linear-gradient(to right, rgba(255,255,255,0), white 70%) 0 100%, radial-gradient(farthest-side at 0% 50%, rgba(0,0,0,0.2), rgba(0,0,0,0)), radial-gradient(farthest-side at 100% 50%, rgba(0,0,0,0.2), rgba(0,0,0,0)) 0 100%; background-repeat: no-repeat; background-color: #fff; background-size: 4em 100%, 4em 100%, 1.4em 100%, 1.4em 100%; background-position: 0 0, 100%, 0 0, 100%; background-attachment: local, local, scroll, scroll; } div[tabindex="0"][aria-labelledby][role="region"].colheaders { background: linear-gradient(white 30%, rgba(255,255,255,0)), linear-gradient(rgba(255,255,255,0), white 70%) 0 100%, radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; background-repeat: no-repeat; background-color: #fff; background-size: 100% 4em, 100% 4em, 100% 1.4em, 100% 1.4em; background-attachment: local, local, scroll, scroll;

Larger tables on narrow or short screens can end up scrolling in two directions. A WCAG auditor may argue this violates WCAG 2.1 SC 1.4.10: Reflow (Level AA), but data tables have an exception. Regardless, you should ensure the column header for the row headers does not disappear when scrolling left-right.

The easiest way to do that is grab the first header cell that is not also a row header and increase its z-index , making sure to also give it a border effect as I covered above:

th:not([scope=row]):first-child { left: 0; z-index: 3; background: linear-gradient(90deg, #666 0%, #666 calc(100% - .05em), #ccc calc(100% - .05em), #ccc 100%); }

Example

View this responsive example on Codepen.

See the Pen Fixed Table Header Demo: Responsive by Adrian Roselli (@aardrian) on CodePen.

Compatibility Notes

Only Firefox supports position: sticky on <thead> , so for maximum compatibility we have to lean on <th> (and few manual coders use <thead> anyway). There is a Chromium bug acknowledging this with some detail; there was a legacy-Edge bug that provided a lot more context but that never made it into the Wayback, so, yeah.

on , so for maximum compatibility we have to lean on (and few manual coders use anyway). There is a Chromium bug acknowledging this with some detail; there was a legacy-Edge bug that provided a lot more context but that never made it into the Wayback, so, yeah. The <caption> element staunchly refuses to honor being sticky. This means in a left-right scroll it disappears when it would be best retained.

element staunchly refuses to honor being sticky. This means in a left-right scroll it disappears when it would be best retained. Safari on macOS and iOS requires -webkit-sticky .

. Safari on macOS and iOS does not reclaim the space from <caption> . This creates a gap with data cells appearing above the row of column headers when scrolling vertically. Because <caption> is part of an accessible table and also feeds the accessible name of the wrapping <div> , you cannot just remove it. Instead, consider using the visually-hidden class on the <caption> (available below).

. This creates a gap with data cells appearing above the row of column headers when scrolling vertically. Because is part of an accessible table and also feeds the accessible name of the wrapping , you cannot just remove it. Instead, consider using the class on the (available below). This will not work in Internet Explorer 11 (or older). I experimented with the Stickybits polyfill and Stickyfill polyfill, but they did not work well with tables for a variety of reasons that are lost to the winds.

If the fixed row is not sufficiently shorter than the container/viewport height, or the fixed column is not sufficiently narrower than the container/viewport width, then this will be terrible. So be careful and test across devices, zoom sizes, and zoom sizes on smaller devices.

Read more about sticky support at Can I Use, and the language in the latest CSS position draft.

visually-hidden Class

/* Proven method to visually hide something but */ /* still make it available to assistive technology */ .visually-hidden { position: absolute; top: auto; overflow: hidden; clip: rect(1px 1px 1px 1px); /* IE 6/7 */ clip: rect(1px, 1px, 1px, 1px); width: 1px; height: 1px; white-space: nowrap; }