Sticky headers on horizonal scrolling 🕸

This is a demo for having a sticky header within a container that has overflow-x set to scroll.

The problem we were facing is that currently, the header for dates is using a JS library to sync its position with the content of the grid. This mostly works, but even on decent computers, there is a significant performance hit.

This is because setting overflow-x: scroll means that the browser will not respect the sticky positioning of the element. We can fix this by separating the sticky header, but at the cost of it no longer tracking horizontally.

See the Pen Position Sticky Table — without scrollsync by Dannie Vinther (@dannievinther) on CodePen.

We can get around this issue by adding a fixed height. We have to be careful because we're doing a double sticky position here; one for the header and search, and another for the grid header (dates). This means that we can't just do top: 0, we have to include an offset of the height of the first sticky header.

In this demo, the categories are part of the grid and are sticky pinned to the side. The top-left cell is pinned to the top and left, with a z-index to keep it on top of the others.

This works well! We have our grid headers sticking to the top on scroll and keeping their position when being scrolled horizontally. There is one problem though, and that has to do with the vertical scrolling.

Since we have given the grid a set height, it is now a set element that contains the grid within itself. This means that if we have a cursor over the grid, it will scroll the grid even if it is barely in view.

What we want is for the grid to act like it's a part of the page without a restricted height while we scroll. We still need it to have a bounded height for the sticky headers to work, so maybe we can toggle this behaviour only after the grid takes up the entire viewport.

Intersection Observers are a way we can monitor what is currently on the viewport. We can specify a target and a threshold, and an event will be triggered when a percentage of the target (specified by the threshold) is visible.

Unfortunately, since we don't know what percentage of the grid will be showing when it is taking up the whole viewport so we can't target it. What we can do is insert an invisible sentinel div just above. When this div is visible (the grid is not in full view) we remove the explicit height and it scrolls normally. When the sentinel disappears, the grid is now in full view and we can set a height to allow the sticky grid elements.

We can even check to see if we are above the sentinel when it is out of view so that we only apply our class for height restrictions when we have scrolled past it. This is what the callback for our Intersection Observer looks like:

const callback = (entries) => {
  const entry = entries[0];

  const grid = document.getElementById("grid");
  const isVisible = entry.isIntersecting;
  const isBelow = entry.boundingClientRect.y <= 0;

  if (!isVisible && isBelow) {
    grid.classList.add("h-screen-gap");
  } else {
    grid.classList.remove("h-screen-gap");
  }
}

A couple of drawbacks here are the scrollbar that jumps in size, and an inability to smoothly scroll up and out of the grid in a single swipe. As far as I can tell there is no way to override the scroll-chaining behaviour to allow for a smooth scroll transition when scrolling up from the grid, but it's not terrible.

Now we can see the final result, which is looking pretty smooth! Have a play with the demo :)

Financial Report
 
Oct 2019
Nov 2019
Dec 2019
Jan 2020
Feb 2020
Mar 2020
Apr 2020
May 2020
Jun 2020
Jul 2020
Aug 2020
Sep 2020
Oct 2020
Nov 2020
Dec 2020
Bank
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Chase
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Credit Card
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Income
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Expenses
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Pizza Delivery
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Accounts
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Other Expenses
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Weapons
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Chimpanzees
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Jan Michael Vincents
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Adoring Fans
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Dogs
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Cats
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Family
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Gold
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Oil
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Tax Haven
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Monopoly Money
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290
Hitmen
21,321
932
12
92,019
281,021
1,213
123,213
12
34
10,293
19,290
123,122
19,111
211,213
290