Linear Interpolation

tldr; Native-like scroll-aware animations:



Learning about lerps

A few days ago, Matt DesLauriers tweeted a beautiful lerp function‏.

function lerp(start, end, t) {
  return start * (1 - t) + end * t
}

lerp(0, 50, 0.5) // 25
lerp(20, 80, 0)   // 20

A lerp/linear interpolation function takes three values, start, end and t (sometimes referred to as alpha). The t value generally ranges between 0 and 1 and can be thought of a bit like a percentage range between 0 and 100. In the first example, a t value of 0.5 is like 50%. So the function returns the ‘50% position’ between 0 and 50: 25

Inverse Lerp

Lerps are great for setting values, ie. we’re 50% through the transition from 20 to 40, therefore set the value as 30.

But how do you actually calculate how far through the transition you are?

Word of warning, I’m still learning about lerps so this approach may have holes!

An inverse lerp function returns the t position.

Say you want to react to the scroll position between 0 and 100px. Let’s call them a and b. With the following inverse lerp, you pass in a, b and the scroll position v.

function invlerp(a, b, v) {
  return ( v - a ) / ( b - a )
}

This is a ‘true’ invlerp function and if you provide a v value that exceeds b, the value returned will be greater than 1, which may not be what you’re after. A clamp function can help this - it’ll hold the value between two limits, usually 0 and 1. Once ES6’d, they look like:

const lerp = (x, y, a) => x * (1 - a) + y * a
const invlerp = (a, b, v) => clamp(( v - a ) / ( b - a ))
const clamp = (v, min = 0, max = 1) => Math.min(max, Math.max(min, v))

Putting it all together

Let’s control some CSS with the scroll position. This uses CSS custom properties to pass the value from JS to CSS.

(function() {
  const item = {
    y1: 70,                // Scroll tracking start point
    y2: 120,               // Scroll tracking end point
    property: '--profile', // CSS Custom property
    from: 1,               // CSS property lower limit
    to: 0.5,               // CSS property upper limit
    value: 0               // Starting point
    suffix: null           // Optional suffix (px, deg, turn)
  }
  
  const scroll = () => {
    // Calculate where we are between y1 and y2
    const n = invlerp(item.y1, item.y2, window.scrollY)
    if (item.value !== n) {
      item.value = n
      // Run the visual updates in the next available frame
      window.requestAnimationFrame(() => {
        // Calculate the new value with the invlerp result
        const property = lerp(item.from, item.to, item.value)
        // Set the CSS Custom Property
        document.documentElement.style.setProperty(item.property, property + (item.suffix || null))
      })
    }
  }

  // Run when we load and scroll
  window.addEventListener('scroll', scroll)
  scroll()
})();

This code essential converts the scroll position (between 70px and 120px) to a value between 1 and 0.5! This can then be set to any CSS property, for example, scaling an image:

img {
  transform: scale(var(--profile));
}

Social profile example

In the video at the top of this post, the blur of the hero image and the size of the profile picture are altered on scroll. The code is very similar to the example above, but I’ve wrapped it all in a loop to control more than one value:

(function() {
  const items = [
    {
      y1: 70,
      y2: 120,
      property: '--profile',
      from: 1,
      to: 0.5,
      value: 0
    },
    {
      y1: 0,
      y2: 120,
      property: '--hero',
      from: 0,
      to: 20,
      value: 0,
      suffix: 'px'
    }
  ]
  
  const scroll = () => {
    items.forEach(item => {
      const n = invlerp(item.y1, item.y2, window.scrollY)
      if (item.value !== n) {
        item.value = n
        window.requestAnimationFrame(() => {
          const property = lerp(item.from, item.to, item.value)
          document.documentElement.style.setProperty(item.property, property + (item.suffix || null))
        })
      }
    })
  }

  window.addEventListener('scroll', scroll)
  scroll()
})();
header img {
  transform-origin: 0% 115%;
  transform: scale(var(--profile));
}

.hero {
  filter: blur(var(--hero))
}

The end result:


Codepen

Taking it too far

If you can control two things, why not four! The core code can stay the same thanks to the loop. It’s a case of passing in a few extra items to track, and setting up the CSS:

const items = [
  {
    y1: 70,
    y2: 120,
    property: '--profile',
    from: 1,
    to: 0.5,
    value: 0
  },
  {
    y1: 70,
    y2: 120,
    property: '--turn',
    from: 0,
    to: 1,
    value: 0,
    suffix: 'turn'
  },
  {
    y1: 0,
    y2: 120,
    property: '--hero',
    from: 0,
    to: 20,
    value: 0,
    suffix: 'px'
  },
  {
    y1: 0,
    y2: 200,
    property: '--bg',
    from: 100,
    to: 300,
    value: 100
  },
]
header img {
  transform-origin: 0% 115%;
  transform: scale(var(--profile));
}

header {
  transform: rotate(var(--turn));
}

.hero {
  filter: blur(var(--hero))
}

main {
  background: hsl(var(--bg), 30%, 70%)
}

This is clearly over the top! But it shows how you can animate all manner of CSS values based of scroll position. And this example could be extended to handle resize events, time changes, maybe even the Ambient Light API!

Codepen

Posted on 05 September 2018 in Web