Back home

Useful GSAP utilities

May 20, 2025

I covered in a recent article about GSAP's function based properties, and gave some simple use cases for what that can do.

GSAP also provides a collection of utility functions that save you from writing a bunch of math and allow you to skip right to the fun, creative stuff.

Keyframes

Sometimes in an animation, you'll be animating a single element through a bunch of different values.

The normal way to do it is to string a bunch of .to tweens together, and you're all good

1gsap.to('.my-element', {
2  y: 10
3})
4.to('.my-element', {
5  y: 20
6})
7// and so on...

And this approach can work if what you're doing is relatively simple. But can get a bit difficult if you're working with staggers and more advanced GSAP concepts.

To bypass that, keyframes are used.

Here's how the basic keyframe setup works:

The basic setup is as follows:

1. Create a gsap tween

1gsap.to('.my-element', {})

2. Pass it the keyframe property + config object

1gsap.to('.my-element', {
2  keyframes: {
3    // This is where you put your animation values
4  }
5})

3. Add the property + an array of values you want to animate through

1gsap.to('.my-element', {
2  keyframes: {
3    x: ['200', '10', '-20'],
4    y: ['10', '-10', '200'],
5    color: ['red', 'blue', '#fff']
6  }
7})

That would get you the same result as chaining a bunch of .to tweens together.

But what's the point of having/knowing a fancy GSAP utility if it can be done with basic tweens?

Staggers.

Let's take this example.

For a recent client project, I had to do a scrolling text effect where the text fills with a gradient as you scroll

When I tried to chain the .to tweens together, I got some really buggy interactions. Because on reverse (or scroll up), the tweens didn't cycle through in the exact same order. If a tween was half finished it would go back and then get stuck on a colour.

Here's a snippet from the code so you can get an idea of why this was useful:

1// create our scroll trigger timeline
2let tl = gsap.timeline({
3  scrollTrigger: {
4    trigger: textContain,
5    start: 'center center',
6    // here I wanted it to be a consistent speed regardless of
7    // paragraph length so I tweaked the value here till it felt natural
8    end: `+=${split.lines.length * 65}`,
9    scrub: true,
10    pin: true,
11    pinSpacing: true,
12  },
13});
14
15// Animate our characters
16tl.to(wrapSelector('.char'), {
17  // Create the keyframe property and its config object
18  keyframes: {
19    // pass through a series of colours we will animate through
20    color: [
21      'rgba(10, 19, 25, 0.2)',
22      'rgba(109, 145, 211, 1)',
23      'rgba(5, 22, 64, 1)'
24    ],
25  },
26  // stagger it so it's got that gradient look
27  stagger: {
28    each: 0.01,
29  },
30});

So here, the animation was all one tween. I included the initial value so we could scroll backwards and everything worked a dream.


Pipe

I couldn't decide whether to put this one first or last.

Pipe is a utility that lets you connect as many other functions as you want, as long as you're passing the values from 1 to the next.

When you call it, it will return a function that will take 1 parameter. The parameter will get passed to the first function in the pipeline. Then whatever value is returned from function 1 gets passed as a parameter to function 2 and so on.

1let { pipe } = gsap.utils;
2
3let example = pipe(
4  function1(param1, param2),
5  function2(param3, param4)
6);
7
8example(1);

In this example, function 1 will have param1, param2 and also have the value of '1' passed through it also.

Then function2 will have param3, param4 and the value of function1 passed to it.

The reason it's so powerful is because it lets you create patterns for your animation. Converting or manipulating values or progress.

Here's an example that connects mouse move to the background color of the body. It uses some utilities we'll dive into later.

1// first lets make our utils easier to get a hold of
2const { pipe, normalize, clamp, interpolate } = gsap.utils;
3
4// lets create a function that converts a number (our mouse position) into a color between orange and green.
5// We start with pipe, as this is the glue that holds the other functions together and passes their values down the chain.
6let yToColor = pipe(
7  // We clamp the value between 0 and the windows height (this is a good practise to save us from abnormalities that could break the code)
8  clamp(0, window.innerHeight),
9  // We then use normalize to convert the clamped number into a progress value between 0 and 1
10  normalize(0, window.innerHeight),
11  // we then use that progress value to give us our color between orange and green.
12  interpolate(['orange', 'green'])
13);
14
15// Update on mouse move
16window.addEventListener('mousemove', (e) => {
17  const color = yToColor(e.clientY);
18  gsap.set('body', {
19    backgroundColor: color
20  });
21});
22
23// Update on resize to keep height accurate
24window.addEventListener('resize', () => {
25  yToColor = pipe(
26    clamp(0, window.innerHeight),
27    normalize(0, window.innerHeight),
28    interpolate(['orange', 'green'])
29  );
30});

Don't worry if it doesn't make a tonne of sense right now. We'll use it a lot so you'll learn through exposure

Clamp

Ah... now onto something familiar if you use CSS.

The gsap clamp utility is exactly the same as the css equivalent.

1let { clamp } = gsap.utils;
2
3// The syntax is as follows
4// clamp(lowestValue, highestValue, valueToClamp).
5
6clamp(1, 5, 6); // = 5
7clamp(1, 5, 0); // = 1
8clamp(1, 5, 2); // = 2

Don't need to write much more than that.

Normalise

Normalise lets you convert a value between 2 points to a percent.

Lets say you've got an element that you're tracking mouseX position of, the far left is 0%, the far right is 100%. How would you convert that to a percentage?

Like this?

1// Get your element
2const el = document.querySelector('.element');
3
4// Add your event listener
5el.addEventListener('mousemove', (e) => {
6  // Get the element's rect, get the x and convert it to a percentage
7  const rect = el.getBoundingClientRect();
8  const x = e.clientX - rect.left;
9  const percentage = Math.min(Math.max(x / rect.width, 0), 1);
10});

Which is perfectly fine. But why not make it just a bit cleaner?

The normalise syntax works as follows:

1let { normalize } = gsap.utils;
2
3// syntax as follows: normalize(lowestNumber, highestNumber, numberToConvert)
4normalize(0, 10, 5);    // = 0.5
5normalize(0, 100, 50);  // = 0.5
6
7// If you leave out the final value it returns a function that lets you reuse your normalize values.
8// For example
9let converter = normalize(12, 36);
10converter(24); // = 0.5

So you could achieve the same effect with:

1let { normalize } = gsap.utils;
2
3let el = document.querySelector('.element');
4
5el.addEventListener('mousemove', (e) => {
6  let rect = el.getBoundingClientRect();
7  let toPercent = normalize(rect.left, rect.right, e.clientX);
8});


Interpolate

now THIS.

This is the one that matters most if I'm being honest.

Interpolate will return a value, inbetween 2 values that you pass to it + a progress (see how useful normalise becomes now?). Those values can be anything (within reason).

Lets do some examples, ranging in usefulness and complexity.

1let { interpolate } = gsap.utils;
2
3// from 0 to 100, with a progress of 0.5 (50%)
4interpolate(0, 100, 0.5); // returns 50
5
6// Pretty simple stuff. Not all that useful yet.
7
8// from red to blue with a progress of 0.5
9interpolate('red', 'blue', 0.5); // "rgb(128, 0, 128)" (purple)
10
11interpolate('2rem', '20rem', 0.5); // "11rem"

Now if we revisit the simple mouse animation from earlier. It should make more sense.

We pipe -> clamp -> normalise -> interpolate

1// first lets make our utils easier to get a hold of
2const { pipe, normalize, clamp, interpolate } = gsap.utils;
3
4// lets create a function that converts a number (our mouse position) into a color between orange and green.
5// We start with pipe, as this is the glue that holds the other functions together and passes their values down the chain.
6let yToColor = pipe(
7  // We clamp the value between 0 and the window's height (this is a good practise to save us from abnormalities that could break the code)
8  clamp(0, window.innerHeight),
9  // We then use normalize to convert the clamped number into a progress value between 0 and 1
10  normalize(0, window.innerHeight),
11  // We then use that progress value to give us our color between orange and green.
12  interpolate(['orange', 'green'])
13);
14
15// Update on mouse move
16window.addEventListener('mousemove', (e) => {
17  const color = yToColor(e.clientY);
18  gsap.set('body', {
19    backgroundColor: color
20  });
21});
22
23// Update on resize to keep height accurate
24window.addEventListener('resize', () => {
25  yToColor = pipe(
26    clamp(0, window.innerHeight),
27    normalize(0, window.innerHeight),
28    interpolate(['orange', 'green'])
29  );
30});

Thanks for coming, folks 👋

Writings

Launch day isn't the finish line

Good projects get messy. Great projects get cleaned up. How should we handle technical debt and real-world refactoring?

Using Functions as Property Values in GSAP (And Why You Probably Should)

GSAP lets you pass _functions_ as property values. I've known this for a while but never really explored it particularly deeply. Over the last couple of weeks I've been testing, experimenting and getting creative with it to deepen my understanding.

Organising JavaScript in Webflow: Exploring Scalable Patterns

Exploring ways to keep JavaScript modular and maintainable in Webflow — from Slater to GitHub to a custom window.functions pattern. A look at what’s worked (and what hasn’t) while building more scalable websites.

Building a Scroll-Based Image Sequencer with GSAP

An exploration in building a scroll-driven image sequence animation using GSAP and HTML5 canvas. Using Avif file compression with the Avif CLI, hosting strategies (Webflow vs AWS), GSAP and the quirks of working with canvas.