Offloading JavaScript With Custom Properties

Sometimes a web development client doesn’t know quite what they want. Trying to guess is difficult and delivering a guess is risky. Where they have a vague idea, I offer to set them up with the means to create what they want for themselves—when they work out precisely what that is.

This was the case recently, where a client wanted some sort of scroll-driven animation, but we couldn’t settle on the exact nature of that animation. All I could advise from a creative standpoint was that it shouldn’t be too obtrusive or disorientating.

Given CSS-based scroll-driven animations are still behind various flags, I set my client up with a small IntersectionObserver script. This tiny module, called CreateObserver, is imported into the foot of the page and initialized with a few arguments.

<script type="module">
  import { createObserver } from 'static/js/createObserver.js';
  
  const observerFunction = entries => {
    entries.forEach(entry => {
      // do something each time a threshold is crossed
    });
  }
  
  createObserver('.section', 5, observerFunction);
</script>

This is pretty serviceable as a general solution, since pretty much anything can be done inside your observerFunction function. With this, I could equip my client with the means to start playing with scroll-based visual effects—and without them having to think much about how IntersectionObserver actually works.

But, as I started writing them example implementations of the callback function, everything started to feel overly JavaScripty. From the README:

const opacityFade = entries => {
  entries.forEach(entry => {
    let ratio = entry.intersectionRatio;
    entry.target.style.opacity = ratio;
  });
}

The above is okay, I guess, especially since the opacity property maps directly to the intersection ratio (0.5 intersecting? 0.5 opacity).

But it gets more complex (and less performant) when you have to coerce the threshold value:

const leftOffset = entries => {
  entries.forEach(entry => {
    let ratio = entry.intersectionRatio;
    let max = -20;
    let offset = max * (1 - ratio);
    entry.target.style.insetInlineStart = `${offset}rem`;
  });
}

And this is if you only want to style the observer entry element itself. What if you really want to affect a descendant element? More DOM querying.

const leftOffset = entries => {
  entries.forEach(entry => {
    let ratio = entry.intersectionRatio;
    let max = 20;
    let offset = max * (1 - ratio);
    let descendant = entry.target.querySelector('.some-descendant');
    descendant.style.insetInlineStart = `${offset}rem`;
  });
}

When you start writing some JavaScript, it kind of lures you into its own way of thinking and it’s easy to forget other technologies are present. While IntersectionObserver is (currently) critical in creating these scroll-driven effects, all we really want from it is the ratio.

It’s entirely CSS that creates the effects on top of this ratio, so it’s within CSS that this work should be done. Not least because someone well versed in CSS and less familiar with JavaScript would then be better able to create and adapt the effects.

Classically—the web is now so old you can refer to “classical” eras within it—the way to offload JavaScript styling to CSS is through the management of classes. With an IntersectionObserver threshold value of 1.0, visibility is considered binary (fully visible or not) and you can simply toggle a single class using the isIntersecting property.

entry.target.classList.toggle('is-intersecting', entry.isIntersecting)

With a set of thresholds (using the array syntax), multiple classes would have to be managed. Something hideous like this:


.intersecting-none {
  transform: translateX(-20rem);
}

.intersecting-one-fifth {
  transform: translateX(-16rem);
}

.intersecting-two-fifths {
  transform: translateX(-12rem);
}

.intersecting-three-fifths {
  transform: translateX(-8rem);
}

.intersecting-four-fifths {
  transform: translateX(-4rem);
}

.intersecting-full {
  transform: translateX(0);
}

(Note: to stop these being hard steps, you can add a transition for the transform property.)

That’s a lot of CSS for one scroll-driven animation, for one element. Worse than that, if someone decides to change the number of thresholds in the JavaScript, they—or someone else—would have to rewrite all these classes in the CSS.

Instead, we can store the intersection ratio directly in a custom property!

style="--intersecting: 0.2"

This makes the ratio available directly in the CSS and can be used in styling calculations:

opacity: var(--intersecting);
transform: translateX(calc(-20rem * (1 - var(--intersecting))));

The number of thresholds now directly produces a resolution for the animation/effect: the custom property updates as many times as the JavaScript callback is told to fire. CSS is reactive to these updates by default.

And since custom property values inherit, we only need to place/update the value on the parent entry and it becomes available to all descendant elements.

let ratio = entry.intersectionRatio;
entry.target.style.setProperty('--intersecting', ratio);

In fact, custom callback functions aren’t needed after all. With just the ratio available directly in our CSS, pretty much everything and anything else can be coded with CSS alone. Which is as it should be.

With classes, we can send CSS static values but with custom properties we can send dynamic ones, which is a major shift in the way we can style state. This is something that has been true for some time—and is extremely well supported—but sometimes it takes solving a small real-world problem to make you appreciate the value of it.

Here’s the general IntersectionObserver solution as a module should you ever have a use for it:

const buildThresholds = steps => {
  let thresholds = [0];
  for (let i = 1.0; i <= steps; i++) {
    let ratio = i / steps;
    thresholds.push(ratio);
  }
  return thresholds;
}

const createObserver = (selector, steps) => {
  if (!IntersectionObserver in window) {
    return;
  }

  let options = {
    root: null,
    rootMargin: '0px',
    threshold: buildThresholds(steps)
  };

  let observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      let ratio = entry.intersectionRatio;
      entry.target.style.setProperty('--intersecting', ratio);
        });
  }, options);

  let nodes = [...document.querySelectorAll(selector)];

  nodes.forEach(node => {
    observer.observe(node);
  });
}

export { createObserver }

If you liked this post, please check out my videos about the web and maybe buy a T-shirt or hoodie or something.