Overflow shadows using the Intersection Observer API
Back in May, I wrote about a little detail in Cushion that I’m calling overflow shadows. Essentially, if a section of the app has more content that can be scrolled to, a subtle shadow appears to indicate this. At that time, I was only dealing with vertical scroll, so it was easy—listen to the
scroll event and show the shadow when scrolled (or the opposite for the bottom of the scroll area). With the new Clients section, I’m adapting the layout to work on narrow viewports, which means horizontally scrolling the table—another opportunity to use the overflow shadows.
While the vertical scroll areas were straightforward, the concept of a horizontally scrolling table is a bit trickier. Now, I need to include shadows for all four sides, but because I’m also using both sticky headers and sticky columns, I need to be more calculated when handling the layering of the shadows and the content. For example, the sticky headers need to be above the top shadow, but below the left, right, and bottom shadows. They also need to be above the sticky columns, but then the sticky columns need to be above the non-sticky columns.
Along with the layering of the shadows, I also had to improve the logic for showing the shadows. Luckily, with the horizontal shadows, I could simply mimic the logic for the vertical shadows, but on a horizontal axis, by showing the left shadow when the section has scrolled, and showing the right shadow whenever the section isn’t scrolled all the way to the end. Everything started coming together, and it looked so nice—especially when the horizontal shadows would appear and disappear as you resize the window. I got so excited about it that I tweeted a video on Twitter. Unexpectedly, the tweet got a ton of likes, and the a lot of folks expressed curiosity for how to accomplish it.
One person in particular asked if I used the Intersection Observer API. We use this API a ton at Stripe to play and pause animations when they enter the viewport, etc., but the question confused me in this instance because it wasn’t clear how you could use the API to toggle shadows. At first, I thought about observing the shadows, but they’re stuck, so they would always be visible. I figured that the person might be mistaken and this wasn’t actually possible, so I carried on… but I’m the kind of person who is easily “inceptioned”. I couldn’t stop thinking about the question, so I took a minute to consider it—the benefit of not needing to listen to every scroll event and having everything handled off the main thread was too great not to consider.
Taking a step back, I thought about the solution that currently worked, using scroll events. If the scroll area has scrolled, show the top and left shadows. If the scroll area isn’t all the way scrolled, show the bottom and right shadows. With this in mind, I tried the simplest, most straight-forward, and least clever approach by putting empty divs at the top, right, bottom, and left of the scroll areas. I called these “edges”, and I observed them using the Intersection Observer API. If any of the edges were not intersecting with the scroll area, I could assume that the edge in question had been scrolled, and I could show the shadow for that edge. Then, once the edge is intersecting, I could assume that the scroll area has reached the edge of the scroll, so I could hide that shadow.
The approach felt too simple, but it was the first one that came to mind. And it worked!—I couldn’t believe it. Using the Intersection Observer API, I could achieve the same result as before, but now it isn’t using the main thread, it isn’t receiving an event for every pixel that the user scrolls, and it doesn’t need to measure any element’s the bounding rect. To a performance-obsessed dev like myself, this was Christmas.
Now that I had the brunt of the solution in place, I had a few edge cases (no pun intended) that I needed to tackle. For one, I noticed that the intersection observing didn’t work on the horizontal edges if I vertically scrolled them out of the scroll area, and vise-versa. This made sense because while they were intersecting on their axis, they were not intersecting on the perpendicular axis. Again, the simplest solution I considered ending up working—sticky edges. Similar to the sticky columns, which stay in view when scrolled, I wanted the same behavior for the edges, so all I needed to do was make the edges sticky to their perpendicular axis—
top: 0 for the left & right edges and
left: 0 for the top & bottom edges. Now, the vertical edges remained in view when scroll horizontally and the horizontal edges remained in view when scrolling vertically.
The other notable edge case related to form fields. With a scroll area that has either sticky columns or sticky rows, this means that the visible scroll area is reduced. If you tab through form fields, which scrolls the form to make sure the field is in view, you’ll notice that the focused field appears behind any sticky element. This is because the scrollable container thinks that all of its bounding rect is visible with regards to the scroll.
Luckily, CSS now has a scroll-padding property. This means that I can reduce the visible scroll area to make sure that focusing a form field keeps it fully in view. In my case, I simply set its value to the sizes of my sticky headers, footers, and columns, and I’m all set.
Overall, I couldn’t be happier knowing how much more performant these overflow shadows are now—and that this dual axis scroll container component just works for any scrollable content. Before I realized that the Intersection Observer API could help in this case, a part of me wondered if the shadows were even worth the scroll events firing all the time. That concern was also pitted against all the positive feedback of users appreciating the overflow shadows. In the end, I’m relieved that I’m able to achieve the original design without compromising it for performance.