Scroll Tracking in Vue Applications – Some gotchas

I recently was on a project for a client that needed a marketing-style single page site. They wanted scroll tracking so that as the user scrolls the section they are currently on would be highlighted in the sticky header.

“Alright, easy enough,” I thought.

Iteration 1: Naive scroll watching

So I boldly went down the easiest path of scroll tracking in Vue. I decided that the header should attach event listeners for when the user scrolls. And since the header knows about the anchor links it’s linked to, it can just look to see what’s in the viewport and highlight that link.

The key to this method is the handleScroll method:

handleScroll() {
    this.idBlocks.find(number => {
        const el = document.getElementById(number);
        if(elementInViewport(el)) {
            this.activeBlock = number;
        }
    });
}

Voila:

See the Pen Vue scroll tracking (1) by Jim Schofield (@oldcoyote) on CodePen.

There’s a problem though… if the blocks are any smaller, or any larger, certain blocks will be missed. If the block is small enough (like the first one) it won’t ever be active! What’s happening? Well, both block 1 and block 2 are in the viewport… in that order, and the active state is then changed from block 1 to block 2 instantly. And there’s no way for just block 1 to be in view, so it’s never highlighted! The nightmare is below…

See the Pen Vue scroll tracking (2) by Jim Schofield (@oldcoyote) on CodePen.

So what’s a coder to do?

Iteration 2: Making decisions about which block to show

So here’s what we could do instead: how about we keep an array of blocks that are on the page and always select the element that comes first? That way, when block one starts to go off screen, the first array element will just be block 2! Perfect!

See the Pen Vue scroll tracking (3) by Jim Schofield (@oldcoyote) on CodePen.

Do you see the issue now? It’s like putting carpet in a room when there’s not enough square feet of carpet! Fix one corner and the others are messed up. We now never see an active state for block 6!

So a cheap workaround is to add a footer, or extra space below the block, so that you can scroll long enough until block 5 is out of the picture.

But there are more problems. What if any of the blocks are taller than the viewport?

Iteration 3: Fixing what it means to be “in the viewport”

Behold… our kingdom is falling:

See the Pen Vue scroll tracking (4) by Jim Schofield (@oldcoyote) on CodePen.

Only elements small enough get recognized! But all is not lost! It is simply that our elementInViewport function is too strict. It’s expecting our element to be both above the bottom of the viewport and below the top. For an element that’s taller than the viewport that can’t happen.

Here are our current “in viewport” possibilities. I’ve xed out the ones I now think are “not in the viewport”.

We want to exclude when a block is outside of the viewport.

So like in a lot of things in math and logic, let’s not determine when the element is in the viewport. Let’s determine when the element is not outside the viewport. Here is my new logic in the elementInViewport function:

function elementInViewport(el) {
  var top = el.offsetTop;
  var height = el.offsetHeight;
  var bottom = top + height;

  while(el.offsetParent) {
    el = el.offsetParent;
    top += el.offsetTop;
  }

  return (
    !(top < window.pageYOffset + (window.innerHeight) && bottom < window.pageYOffset + (window.innerHeight)) &&
      !(top > (window.pageYOffset + (window.innerHeight)) && bottom > window.pageYOffset + (window.innerHeight))
  );
}

Much better.

See the Pen Vue scroll tracking (5) by Jim Schofield (@oldcoyote) on CodePen.

Heck yes! It’s working. But there’s still an issue. Can you catch it?

Iteration 4: Fixing what it means to be in the viewport… again

Alright… so if block 6 is much smaller, it’s never going to be recognized. Actually, because it’s so much smaller, sometimes 5 isn’t even recognized!

See the Pen Vue scroll tracking (6) by Jim Schofield (@oldcoyote) on CodePen.

This is about when I started to lose all hope.

But don’t lose hope! There may be other solutions. Here is some hackiness that I put together with the following assumptions:

  1. Blocks will always be a minimum height of 40vh
  2. There will be a footer
  3. *crosses fingers*

See the Pen Vue scroll tracking (7) by Jim Schofield (@oldcoyote) on CodePen.

I adjust the rules about how far in to the viewport the element has to be. It works relatively good, but it makes me nervous. There is still something deeply unsatisfying about this.

What other options do we have besides tweaking these viewport rules?

Iteration 5: I <3 intersection observer

A caveat before I go into this method: I still haven’t found a bulletproof method for this. I just think this API is really interesting and it offloads work so you’re not tying up the main thread.

Also, support for intersectionObserver is not great, but it was good enough for my client’s requirements for browser support.

Basically, doesn’t work in IE and Safari…

Here’s how it works. You pass the intersection observer an element and a callback function, and when the element intersects the viewport the callback is called. (There are some ways to have it check intersecting other things, like other containers and divs, but for now let’s let it default to the viewport.)

You can define “thresholds” so that when a certain ratio of the block is in the viewport the function is triggered. But herein lies the rub: it’s really great for running code like lazy loading where you just need to know whether an element is in the viewport. It’s not so great at determining which section is the important section on the screen and where they are. Basically the problem isn’t removed, we just have a better(?) scroll tracker. So we’ll just set it up and call it the ‘naive intersectionObserver implementation.’

See the Pen Vue scroll tracking (8) by Jim Schofield (@oldcoyote) on CodePen.

“What a second!” you say, “That’s worse! If I scroll slowly, the active state jumps all over the place!”

Yeah, it does. Our function is called every time an element is either 50% in the viewport or 50% out of the viewport. So if we just always set the first entry to be active, it will jump around as a we scroll.

So why use this? Intersection observers are performant, so instead of firing a million scroll events the observer only fires at these thresholds. We may not be able to identify the right block simply by what’s firing, but this does give us effectively a more performant scroll event. We’re going to change this threshold option into an array so this event fires a whole lot, and then we’re going to have to try some fancy shmancy decision to see which block is really the active on…

Iteration 6: intersection observer and then a fancy shmancy decision function

Alright, so we’re going to do some math with our blocks position. And we’re going to use intersection observer to trigger a ‘performant scroll event.’ Basically, we’re going to figure out what percent each of the blocks are down the document, and track when the scrollTop property is “around” that percentage. It has to be a little fancy because we want our decision to take into account that certain scrollTop values will never be possible since the viewport stops you from scolling past a certain point. Here is my visualization:

If we remove the viewport height and scaled down section Y positions so they are a proportion of what's left of the document, we now have "breakpoints" for the scrollTop property of our document we can test against.

If you think about it, each scrollTop of each un-transformed block will shrink by the proportion that the viewport height takes up on the document height.

That was a lot. Here’s basically the formula I came up with:

const newBlockOffset = oldOffset - oldOffset * (viewportHeight / documentHeight);

Let’s give it another try.

See the Pen Vue scroll tracking (9) by Jim Schofield (@oldcoyote) on CodePen.

It works…. better… ? That’s as far as I got for trying to accurately track what blocks are on screen.

You may be asking yourself, “Couldn’t we just have done that fancy logic with a scroll event?”

Yup.

But intersectionObserver!

That’s it… for now

So, this was showing a couple ways to do scroll tracking inside of a Vue component. Am I missing some easy way to do this? Am I way off base? Let me know @jschof or in the comments.

I also ran into a bunnnnnch of other gotchas while developing a psuedo-cms. That, my friends, will be our next post.

Leave a Reply

Your email address will not be published. Required fields are marked *