DEV Community

Cover image for So hard to make table header sticky
Jennie
Jennie

Posted on • Updated on

So hard to make table header sticky

It is classic to display data with a table, each row is a record, each column is a data field, and the page could show quite a few data records, which requires user to scroll vertically enable to overlook large amount of data. And this, often requires keeping table header in our sight all the time, aligning with the columns, so we can tell what does each cell mean easily.

My first reaction was to try on <thead>, and it didn't work.

Then I found the blog "Position Sticky and Table Headers" by Chris Coyier, and he explained this clearly:

The issue boils down to the fact that stickiness requires position: relative to work and that in the CSS 2.1 spec.

And he provided one solution:

You can’t position: sticky; a <thead>. Nor a <tr>. But you can sticky a <th>

And a decent example:

Then I tried this on the platform I was working on. It turns out it does not work either. Why??? It turned out thanks to this killer my dear overflow: hidden;.

MDN explained why this happens:

Note that a sticky element "sticks" to its nearest ancestor that has a "scrolling mechanism" (created when overflow is hidden, scroll, auto, or overlay), even if that ancestor isn't the nearest actually scrolling ancestor. This effectively inhibits any "sticky" behavior (see the Github issue on W3C CSSWG).

Well, this sounds like a case CSS standard forgot to cover. Then you may think OK in this case, let us try to avoid wrapping tables in a overflow:hidden element. But if you maintain this page for a long term, or team working on the same page, can you make sure your sticky element will never been wrapped in an element with overflow:hidden? I bet no.

So I keep searching for a sustainable solution, and what I found was just suggesting giving up on <table> tag, or giving up the table display, and use flex instead like this:

You know unlike table cells, flex elements will not automatically align to each other. Enable to align the "cells", you will need to set a width on each "cell" element.

That's totally fine for one or two tables I have to say. But what if I am working on a data management platform, which does constantly add new tables like that? And sometimes may add a new column into a long lived table which breaks the perfect size setting it has?

That will be a disaster if you does not have a GUI tool like the classic Dreamweaver to help.

Now I think it's time to use some Javascript. I recall that before position: sticky is introduced, it was popular to usea jQuery plugin to clone a component out, hide it by default, and displays when the user scrolls in a calculated range. Like this one.

It perfectly works in jQuery app, which uses css selectors to bind the elements with events, and the cloned elements will keep the original arrtibutes, all you have to keep in mind is to write event binding selectors carefully to make sure the cloned header will still respond to the events you required.

But in framework like react, it is tricky to do this. Imagine that designer designed this kind of fancy table:

How to make sure the cloned header works and looks exactly same as the original header?

So I think instead of clone, why don't I just fixed the size of each header cells when user scrolls the table in and out the viewport, and make them position: fixed to avoid being affected by overflow: hidden, and I may enjoy the flexible cell width? Although it will be affected by position: relative, yet still a lot better.

And here's what I came out:

Instead of listening on scroll event, I tried IntersecionObserver API for a better performance, and modern browser has supported IntersectionObserver quite well:

canIuse IntersectionObserver

Unlike scroll event, it's a class accept a callback and options:



const observer = new IntersectionObserver(callback, options);
observer.observe(targetElement);
observer.unobserve(targetElement);


Enter fullscreen mode Exit fullscreen mode

And it only calls the callback function when the target element displayed cross a given raitio of the viewport.

Rather than reporting every infinitesimal change in how much a target element is visible, the Intersection Observer API uses thresholds. When you create an observer, you can provide one or more numeric values representing percentages of the target element which are visible. Then, the API only reports changes to visibility which cross these thresholds.

Here's a blog explaining IntersectionObserver in details: An Explanation of How the Intersection Observer Watches. Check it out!

Because of this special setting, I observed 2 empty helper elements as start point, and end point. When the observer callback triggered, I check the top offset of the start point and the end point via element.getBoundingClientRect(). If the top of the start point become negative, it means the table header starts to leave the viewport. By contrast, if the top of end point become negative, it means the whole table almost leaves the viewport.



const startEl = React.useRef(null);
const endEl = React.useRef(null);

React.useEffect(() => {
  const states = new Map();
  const observer = new IntersectionObserver(
    entries => {
      entries.forEach(e => {
        states.set(e.target, e.boundingClientRect);
      });
      const { top } = states.get(startEl.current) || {};
      const { top: bottom } = states.get(endEl.current) || {};
      if (top < 0 && bottom > 0) {
        show();
      } else {
        hide();
      }
    },
    {
      threshold: [0],
    }
  );
  observer.observe(startEl.current);
  observer.observe(endEl.current);
}, [])


Enter fullscreen mode Exit fullscreen mode

The scrolling down experience looks like this:

scrolling down experience

The scrolling up experience looks like this:

scrolling up

The star point is simply placed on top of the table, but the end point is somewhere above the end of the table to create better user experience since I feel it looks wierd when the last row is over half covered by the sticky header in the end. That's why you see this calculation:



const thead = el.current.querySelectorAll('thead');
const rows = el.current.querySelectorAll('tr');
const theadHeight = (thead && thead[0].getBoundingClientRect() || {}).height || 0;
const lastRowHeight = (rows && rows[rows.length - 1].getBoundingClientRect() || {}).height || 0;
endEl.current.style.top = `-${theadHeight + lastRowHeight/2}px`;


Enter fullscreen mode Exit fullscreen mode

Working with CSS:



.end-buffer-area {
  z-index: -1;
  position: relative;
}


Enter fullscreen mode Exit fullscreen mode

Then we toggle a CSS class .stickyHeader on the wrapper to control the displaying of the sticky header:



.header-column {
  ...
}
.stickyHeader .header-column {
  position: fixed;
  top: 0;
}


Enter fullscreen mode Exit fullscreen mode

The first thing you may notice that after the header cell become position: fixed, it no longer aligns to the other cells, everything gets messed. So I need to find a way to keep the header cell size, and position at the same time.

What I did was wrap the header cell content with a div first:



<thead>
  <tr>
    <th><div className="header-column">Name</div></th>
    <th><div className="header-column">Age</div></th>
    <th><div className="header-column">Address</div></th>
  </tr>
</thead>


Enter fullscreen mode Exit fullscreen mode

When it shows, I calculate the sizes, set on both th and .header-column to maintain the table alignment:



const show = () => {
  el.current.querySelectorAll('.header-column').forEach(
    col => {
      if (!col.parentElement) { return; }
      const { width, height } =
            col.parentElement.getBoundingClientRect() || {};
      col.style.width = col.parentElement.style.width = `${width}px`;
      col.style.height = col.parentElement.style.height = `${height}px`;
      `${width}px`;
    }
  el.current.classList.add("stickyHeader");
};


Enter fullscreen mode Exit fullscreen mode

And some CSS to ensure they looks same:



thead th {
  padding: 0;
}
.header-column {
  height: auto !important;
  padding: 10px;
  box-sizing: border-box;
}
.stickyHeader .header-column {
  background: inherit;
}


Enter fullscreen mode Exit fullscreen mode

Next you may notice it will have a wierd jumping out behaviour makes the sticky header appearance look a bit unnatural. This is because when user scroll fast, we will see the header leaves out out of the viewport before IntersectionObserver triggers the callback. Right, our work arounds can never achieve the effect of the browser's native integration.

But we can make it feels better via animation. So I added this simple CSS animation as a finishing:



.stickyHeader .header-column {
  top: 0;
  animation: slideDown 200ms ease-in;
}

@keyframes slideDown {
  0% {
    transform: translateY(-50%);
  }
  100% {
    transform: translateY(0%);
  }
}


Enter fullscreen mode Exit fullscreen mode

Here it goes.

But you can tell this solution is still very rough. Some restrictions like:

  • need to carefully style the header
  • not responsive

Are able to be fixed via more careful checks and events handling.

Hope you enjoy the exploration of new solutions with me :).

Top comments (10)

Collapse
 
joakimbeng profile image
Joakim Carlstein

Good post!
Sticky table headers are a real mess to get working flawlessly!
I was hoping that your solution would work in my case as well but I'm afraid not...
You'll notice the issue when adding more columns to the table, and shrinking the window so that the horizontal scrollbar shows. When you scroll horizontally the position: fixed headers won't be aligned with their th counterparts 😔

Collapse
 
jennieji profile image
Jennie

Right, considering resizing will introduce more complexity.

Collapse
 
xtender profile image
𝙿𝚊𝚞𝚕 𝙺𝚊𝚖𝚖𝚊

I just wanted to thank you for this article, after founding a lot of shit by searching for a solution for this problem I finally landed here.
I replaced the IntersectionObserver with a scroll event because of IE11 and some minor problems with other problems.

Collapse
 
ankit_goyal_7c0cd4d12fc59 profile image
Ankit Goyal

Hi Jennie, thank you for the incredible post! I've looked everywhere to get sticky headers working and your code WORKED!

Quick question, my table needs to scroll horizontally as well but the sticky headers don't scroll horizontally when I scroll my content horizontally. What do you recommend I do?

Collapse
 
jennieji profile image
Jennie

Hi Ankit, I just see your message. And the post is a bit out-dated! Try this awesome easy approach for the modern browsers instead: css-tricks.com/making-tables-with-...

Scrolling horizontally at the same time would be tricky with my hacky approach as the header and table no longer syncs position.

While we can make the vertical "sticky" scrolling look more natural by applying simple animation on the header, horizontal scroll will not be that straight forward as I can imagine. In this case I will consider to use a special controlled scroll component which allows me to know control the scroll speed animation and apply the same animation to the header. Honestly it's not worth to add such heavy things.

Collapse
 
rockstarrprogrammerr profile image
rockstarr-programmerr

That discovery of overflow: hidden is really nice

Collapse
 
shubham_687 profile image
Shubham Chhabra

Thank for you for such a informative blog!
One question though, the easy approach css-tricks.com/making-tables-with-... you mentioned below in the comments will have the issue that parent containers should not have overflow property set, right?

Collapse
 
mefaba profile image
AW A RE

Thats crazy how hard creating a sticky table header. I am having headaches right now because of this.

Collapse
 
boucdur profile image
boucdur

Great article. that's nice no read someone has followed the same tests and interrogation.
Did you end up with a better solution ?

Collapse
 
jennieji profile image
Jennie

Nope so far.