Fixing a table header on a horizontally scrolling table
Tags: react, javascript, frontend
Published: 2024-01-11 13:17:07

You would think this is easy. But it really isn’t.

## Chapter 0: The environment

This entire post is based on a React application, so while it’s not mandatory, it would help if you had at least basic knowledge of React and React Hooks.

## Chapter 1: The initial problem

In a project we’ve been recently working on we faced a very specific issue: the client wanted a large table.

What do I mean by _large_? Well, I’ve seen tables that have 15+ columns and are much wider than the screen. Luckily this was not one of those tables, but it was wider than the available space anyways.


![Table with Pokémon info. Table overflows to the right](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a7mkymzz6g75wz51cqtj.png)

In addition to this, the table needed to have buttons to be easily scrollable.

In order to achieve this, we wrapped the table in a `<div>` that had its `width` locked to a specific value (something like 80% of the screen's width). We then added some buttons and a `scrolling` function that changes the scroll position of the wrapper. The scrolling function looked something like this:

![useCallback hook for horizontally scrolling table](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r7g2n227sk6sjexdcuol.png)

This resulted in a nice table with the horizontal scroll and also buttons to scroll in case you don’t have a touchpad.

![Animation of table scrolling vertically](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v8gl5wh0t9jd29vu6lft.gif)

This was good, and working as expected.

## Chapter 2: New requirements arise

The table in this state was approved and deployed to production. Everything was looking fine until some users started requesting that we leave the header fixed to the top when scrolling. That way they would be able to recognize what each value was without needing to scroll back and forth to check the column name.

Our first instinct was what I think most people would do: we added `position: sticky` to the `thead` element of the table.


![CSS code for sticky table header](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aamtf9qneqvao6hvaljv.png)

This seems too easy to be true, right? That’s because it is, please see what happens when we do this:


![Animation of table header scrolling away even when the elements have position: sticky defined](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rgrv9u1ofpceveo2lx2x.gif)

We can see that the table elements effectively have the position: sticky attribute, but after scrolling a bit they leave the screen.

This is because, as per [CSS specifications](https://medium.com/r/?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FCSS%2Fposition%23sticky):

> This value always creates a new stacking context. 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.

So the problem here is:

- `.layout__content` is scrolling.
- `.table__wrapper` is not scrolling.
- `.table` thead is sticky relative to `.table_wrapper`.

What we wanted was `.table thead` to be sticky relative to `.layout__content`. This is not possible because even though `.table__wrapper` only has overflow-x specified it still has an overflow property, so the `.table thead` sticks to it instead.

## Chapter 3: First approach

So, taking the specifications into account, let’s remove the `overflow-x` from `.layout__content` and see what happens.

![CSS code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xqrewf5fef8zbreh2m6l.png)

What happens, then?

![Animation of table with sticky header](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jtx6kuplfgktdrstcpi7.gif)

So, it looks like it worked… or did it?

![Animation showing sticky header but broken horizontal scrolling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p18v2ba3ye20hwp6rkvf.gif)

Oh, no! Our scroll buttons stopped working. Not only that, now scrolling the table scrolls the entire page, including the title. That was not supposed to happen.

But, hey! At least our headers are fixed now!

## Chapter 4: Don’t tell me what to do!

So after searching for solutions (and scratching our heads) for quite some time, we came up with a solution for the issue at hand: let’s use JavaScript and CSS!

The basic idea is this:

Let’s see where the table header is.
Let’s see how far down the user has scrolled. a. If the user has scrolled below the header start, let’s `translate` the `thead` element so it keeps at the top. b. If not, remove the `translate` property from the table header.
Since we were using React for this app, we made use of some `refs` to keep track of the actual DOM elements.

The code turned out to look something like this (I wrote it as a hook for reusability):

![React hook for table scrolling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bhnzzd5gqynohxduar68.png)


Let’s break the code down:

Firstly, we declare two `refs`, one for the table wrapper and one for the table headers.

You may ask _why_ we declare a ref for the table wrapper instead of the table itself. The reason behind this is that further down the code we use some positioning to calculate how much the user has scrolled, and the `offsetTop` is relative to the parent, so the table always has a `offsetTop` equal to zero relative to its parent (the wrapper). The wrapper is supposed to be a direct child of the scrolling element (in our case, the `layout__content` element).

We then write a `useEffect` hook that is in charge of adding an event listener to the scrolling element so that when it scrolls we act accordingly.

What do we mean by _acting accordingly_? Well, we should check the position of the header relative to the scrolling element, but as we will be moving it, we should check the table position instead, and that’s where we use the table wrapper, as that element will not move around relative to the content element. From then on we can see if the parent element has scrolled past the header position, and if so, we `translate` that element by that scroll position difference (`scroll position` - `header position`). If the `scroll position` is above the header position we just remove that `translate` property.

It’s also important to remember to remove that event listener, otherwise after navigating to another section of the app we could be referencing elements that are no longer there, and the app would crash. To accomplish this we return a function that does exactly that inside our `useEffect`.

So, _did it work_?

![Animation of fully functional table: horizontal scrolling and fixed header](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pit7pulcbvg1u0p8hye5.gif)

Yes! It’s not 100% perfect, the header may jump a little bit but that’s because of the event loop, but that’s out of the scope of this blog post.

But there’s something that pops up in the dev tools:

![Firefox alerts of scroll-linked positioning effect](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/proydj10r0d0wjis0u0f.png)

Following that link we see that Firefox [recommends](https://medium.com/r/?url=https%3A%2F%2Ffirefox-source-docs.mozilla.org%2Fperformance%2Fscroll-linked_effects.html%23example-1-sticky-positioning) using `position: sticky` for what we are doing.

Well, Firefox, we’d use `position: sticky` if it worked like we wanted it to work.

## Final thoughts
This problem was quite entertaining to work on, but I think that it’d be nice if the CSS specification could take this into account. Maybe a property called `sticky-anchor`` with values `"ancestor" | "screen"`` would be nice. I think `ancestor` would keep the current behavior (and be the default value) and `screen` would only take into account the whole screen. If that were the case we would be able to skip all that code and just have CSS like this:

![Recommended code with position: sticky and sticky-anchor: screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nx1634gdfusat8yrdc2h.png)

Also, this method is not the best, scrolling too fast or on mid-to-low end devices causes some jittering in the header, so if you need a 100% perfect solution you should look into the event loop and animation loop for it to be even better.