6.6 C
New York
Thursday, January 15, 2026

A Website for Sore Eyes: Combining GSAP and Webflow to Showcase Flim




Free GSAP 3 Express Course


Be taught fashionable internet animation utilizing GSAP 3 with 34 hands-on video classes and sensible initiatives — good for all talent ranges.


Test it out

Tech Stack and Instruments

Flim was in-built Webflow. We made good use of the built-in performance and added a number of JavaScript libraries on prime to assist issues go easily.

As all the time, we used GSAP for our animations. The brand new GSAP interactions constructed into Webflow weren’t accessible on the time we began engaged on this, so our interactions are constructed utilizing customized code, which works very well too.

We additionally used Lenis for easy scrolling, Lottie for the baked animations, and Rapier for physics.

Transitions

We wished a fast, clear, masks animation for our web page transitions. To take action, we created two easy GSAP timelines. 

The primary one hides the web page with a repeating sample. It performs at any time when we click on on an inner hyperlink, earlier than navigating to the web page.

const hideTl = gsap.timeline();

hideTl.set(loaderElem, { visibility: 'seen' });

hideTl.fromTo(maskElem, {
  '--maskY': '0%',
}, {
  '--maskY': '100%',
  period: 0.5,
  ease: 'power2.inOut',
  onComplete: () => {
    window.location = vacation spot;
  }
});

The opposite one reveals the web page once more, enjoying when the web page masses.

const showTl = gsap.timeline();

showTl.addLabel('begin', 0.5);

showTl.fromTo(maskElem, {
  '--maskY': '100%',
}, {
  '--maskY': '200%',
  period: 0.5,
  ease: 'power2.inOut',
}, 'begin')
.set(loaderElem, {
  visibility: 'hidden'
});

And for the house web page, we’ve a customized animation added, the place the brand animates in with the transition. We created one other timeline that performs concurrently the transition masks. We are able to add the brand new dwelling timeline to the timeline we already created, utilizing the begin label we arrange.

const homeTl = gsap.timeline();

// …

showTl.add(homeTl, 'begin');

Textual content Animations

Like we frequently do, we wished the web page’s textual content to seem when it comes into view. Right here, we wished every line to be staggered once they appeared. To take action, we use a mix of GSAP’s SplitText and ScrollTrigger.

We first focused each textual content container that wanted the build-on animation. Right here, we focused particular courses, however we might additionally goal each component that has a selected attribute.

We first create a timeline that begins when the component comes into view with ScrollTrigger.

Then we create a SplitText occasion, and on break up, we add a tween animating every line sliding up with a stagger. We additionally retailer that tween to have the ability to revert it if the textual content is break up once more.

const tl = gsap.timeline({
  paused: true,
  scrollTrigger: {
    set off: el,
    begin: 'prime 90%',
    finish: 'prime 10%',
    as soon as: true
  }
});

let anim;

SplitText.create(el, {
  kind: 'strains',
  autoSplit: true,
  masks: 'strains',
  onSplit(self) {
    if (anim) anim.revert();

    anim = gsap.fromTo(self.strains, {
      yPercent: 100,
    }, {
      yPercent: 0,
      period: 0.8,
      stagger: 0.1,
      ease: 'power3.inOut',
    });
    tl.add(anim, 0);
  }
});

We even have a enjoyable animation on the button’s hover. Right here, we use GSAP’s SplitText with characters, and animate the opacity of these characters on mouse hover. We goal the containers and the textual content components inside, with the info attribute data-hover’s worth. 

const hoverElems = doc.querySelectorAll('[data-hover="container"]');

hoverElems.forEach(elem => {
  const textElem = elem.querySelector('[data-hover="text"]');

  // animation

  let tl;

  SplitText.create(el, {
    kind: 'chars',
    onSplit(self) {
      if (tl) tl.revert();

      tl = gsap.timeline({ paused: true });

      tl.to(self.chars, {
        period: 0.1,
        autoAlpha: 0,
        stagger: { quantity: 0.2 },
      });
      tl.to(self.chars, {
        period: 0.1,
        autoAlpha: 1,
        stagger: { quantity: 0.2 },
      }, 0.3);
    }
  });

  // occasions

  const handleMouseEnter = () =>  tl.isActive()) return;
      tl.play(0);
  

  elem.addEventListener('mouseenter', handleMouseEnter);
});

Lotties

Lotties may be simply built-in with Webflow’s Lottie element. For this challenge although, we opted so as to add our Lotties manually with customized code, to have full management over their loading and playback.

A lot of the lottie animations load a distinct lottie, and alter between the variations relying on the display measurement.

Then, the principle problem was enjoying the start of the animation when the component got here into view, and enjoying the top of the animation after we scroll previous it. For some sections, we additionally needed to watch for the earlier component’s animation to be over to begin the following one.

Let’s use the title sections for instance. These sections have one lottie that animates in when the part comes into view, and animates out after we scroll previous it. We use two ScrollTriggers to deal with the playback. The primary scroll set off handles enjoying the lottie up till a selected body after we enter the realm, and enjoying the remainder of the lottie after we go away. Then, the second ScrollTrigger handles resetting the lottie again to the beginning when the part is totally out of view. That is necessary to keep away from seeing empty sections once you return to sections which have already performed.

let hasEnterPlayed, hasLeftPlayed = false;

// get whole frames from animation knowledge
const totalFrames = animation.animationData.op;

// get the place the animation ought to cease after coming into 
// (both from an information attribute or the default worth)
const frameStopPercent = parseFloat(
  part.getAttribute('data-lottie-stop-percent') ?? 0.5
);

// get the particular body the place the animation ought to cease
const frameStop = Math.spherical(totalFrames * frameStopPercent);

// scroll triggers

ScrollTrigger.create({
  set off: part,
  begin: 'prime 70%',
  finish: 'backside 90%',
  onEnter: () => {
    // don't play the enter animation if it has already performed
    if (hasEnterPlayed) return;

    // play lottie phase, stopping at a selected body
    animation.playSegments([0, frameStop], true);

    // replace hasEnterPlayed flag
    hasEnterPlayed = true;
  },
  onLeave: () => {
    // don't play the go away animation if it has already performed
    if (hasLeftPlayed) return;

    // play lottie phase, beginning at a selected body
    // right here, we don't drive the phase to play instantly 
    // as a result of we wish to watch for the enter phase to complete enjoying
    animation.playSegments([frameStop, totalFrames]);

    // replace hasLeftPlayed flag
    hasLeftPlayed = true;
  },
  onEnterBack: () => {
    // don't play the go away animation if it has already performed
    if (hasLeftPlayed) return;

    // play lottie phase, beginning at a selected body
    // right here, we do drive the phase to play instantly
    animation.playSegments([frameStop, totalFrames], true);

    // replace hasLeftPlayed flag
    hasLeftPlayed = true;
  },
});

ScrollTrigger.create({
  set off: part,
  begin: 'prime backside',
  finish: 'backside prime',
  onLeave: () => {
    // on go away, we would like the lottie to have already got entered after we scroll 
    // again to the part

    // replace the state flags for coming into and leaving animations
    hasEnterPlayed = true;
    hasLeftPlayed = false;

    // replace lottie segments for future play, right here it begins on the finish 
    // of the enter animation
    animation.playSegments(
      [Math.min(frameStop, totalFrames - 1), totalFrames], true
    );

    // replace lottie playback place to begin and cease lottie
    animation.goToAndStop(0);
  },
  onLeaveBack: () => {
    // on go away again, we would like the lottie to be reset to the beginning

    // replace the state flags for coming into and leaving animations
    hasEnterPlayed = false;
    hasLeftPlayed = false;

    // replace lottie segments for future play, right here it begins on the 
    // begin of the enter animation
    animation.playSegments([0, totalFrames], true);

    // replace lottie playback place to begin and cease lottie
    animation.goToAndStop(0);
  },
});

Eyes

The eyes dotted across the web site had been all the time going to wish some character, so we opted for a cursor observe mixed with some more and more upset reactions to being poked.

After (quite a bit) of variable setup, together with constraints for the actions throughout the eyes, limits for the way shut the cursor must be to a watch for it to observe, and numerous different randomly assigned values so as to add selection to every eye, we’re prepared to begin animating.

First, we monitor the mouse place and replace values for the iris place, scale and whether or not it ought to observe the cursor. We use a GSAP utility to clamp some values between -1 and 1.

// Constraint Utility
const clampConstraints = gsap.utils.clamp(-1, 1);

// Observe Mouse
const trackMouse = (e) => {
  // replace eye positions if space is dynamic
  if (isDynamic) {
    updateEyePositions();
  }

  mousePos.x = e.clientX;
  mousePos.y = e.clientY - space.getBoundingClientRect().prime;

  // replace eye variables
  for (let x = 0; x < eyes.size; x++) {
    let xOffset = mousePos.x - eyePositions[x].x,
        yOffset = mousePos.y - eyePositions[x].y;

    let xClamped = clampConstraints(xOffset / movementCaps.x),
        yClamped = clampConstraints(yOffset / movementCaps.y);

    irisPositions[x].x = xClamped * pupilConstraint.x;
    irisPositions[x].y = yClamped * pupilConstraint.y;
    irisPositions[x].scale = 1 - 0.15 * 
      Math.max(Math.abs(xClamped), Math.abs(yClamped));

    if (
      Math.abs(xOffset) > trackingConstraint.x || 
      Math.abs(yOffset) > trackingConstraint.x
    ) {
      irisPositions[x].monitor = false;
    } else {
      irisPositions[x].monitor = true;
    };
  };
};

space.addEventListener('mousemove', trackMouse);

We then animate the eyes to these values in a requestAnimationFrame loop. Creating a brand new GSAP tween on each body right here might be overkill, particularly when issues like quickTo exist, nevertheless this enables us to keep up variation between the eyes, and we’re hardly ever monitoring greater than a pair at a time, so it’s value it.

const animate = () => {
  animateRaf = window.requestAnimationFrame(animate);

  for (let x = 0; x < eyes.size; x++) {
    // if monitor is fake do not trouble
    if (!irisPositions[x].monitor) proceed;

    // if this eye was in the midst of an ambient tween kill it first
    if (eyeTweens[x]) eyeTweens[x].kill();
 
    // irides are the plural of iris
    gsap.to(irides[x], {
      period: eyeSpeeds[x],
      xPercent: irisPositions[x].x,
      yPercent: irisPositions[x].y,
      scale: irisPositions[x].scale
    });
  };
};

let animateRaf = window.requestAnimationFrame(animate);

Subsequent we’ve a perform that runs each 2.5 seconds, which randomly applies some ambient actions. This creates a bit extra character and much more selection, because the look path, rotation, and the delay, are all random.

const animateBoredEyes = () => {
  for (let x = 0; x < eyes.size; x++) {
    // skip eyes which are monitoring the cursor
    if (irisPositions[x].monitor) proceed;

    // if this eye was in the midst of an ambient tween kill it first
    if (eyeTweens[x]) eyeTweens[x].kill();

    // get a random place for the pupil to maneuver to and the matching scale
    const randomPos = randomPupilPosition();

    // possibly do a giant spin
    if (Math.random() > 0.8) gsap.to(inners[x], {
      period: eyeSpeeds[x],
      rotationZ: '+=360',
      delay: Math.random() * 2
    });

    // apply the random animation
    eyeTweens[x] = gsap.to(irides[x], {
      period: eyeSpeeds[x],
      xPercent: randomPos.x,
      yPercent: randomPos.y,
      scale: randomPos.scale,
      delay: Math.random() * 2
    });
  };
};

// Ambient motion
animateBoredEyes();
let boredInterval = setInterval(animateBoredEyes, 2500);

Lastly, when the consumer clicks a watch we would like it to blink. We additionally need it to get angrier if the identical eye is picked on a number of instances. We use a mix of GSAP to use the clip-path tween, and CSS class adjustments for different numerous model adjustments.

// Blinks
const blinks = [];

const blinkRandomEye = () => {
  // choose a random eye and play its blink at regular pace
  const randomEye = Math.ground(Math.random() * eyes.size);
  blinks[randomEye].timeScale(1).play(0);
};

const blinkSpecificEye = (x) => {
  blinkInterval && clearInterval(blinkInterval);

  // set which class shall be utilized based mostly on earlier clicks of this eye
  const clickedClass = 
    eyeClicks[x] > 8 ? 'livid' : eyeClicks[x] > 4 ? 'indignant' : 'shocked';

  // the time till this eye is re-clickable will increase because it will get angrier
  const clickedTimeout = 
    eyeClicks[x] > 8 ? 3000 : eyeClicks[x] > 4 ? 2000 : 500;

  // enhance the press depend
  eyeClicks[x]++;

  // apply the brand new class
  eyes[x].classList.add(clickedClass);

  // fast agitated blink
  blinks[x].timeScale(3).play(0);

  // reset after cooldown
  setTimeout(() => {
    eyes[x].classList.take away(clickedClass);
    blinkInterval = setInterval(blinkRandomEye, 5000);
  }, clickedTimeout);
}

const setupBlinks = () => {
  // loop by means of every eye and create a blink timeline 
  for (let x = 0; x < eyes.size; x++) {
    const tl = gsap.timeline({
      defaults: {
        period: .5
      },
      paused: true
    });

    tl.to(innerScales[x], {
      clipPath: `ellipse(3.25rem 0rem at 50% 50%)`
    });

    tl.to(innerScales[x], {
      clipPath: `ellipse(3.25rem 3.25rem at 50% 50%)`
    });

    // retailer the blinks in order that the random blink 
    // and particular eye blink features can use them
    blinks.push(tl);

    // blink when clicked
    eyes[x].addEventListener('click on', () => blinkSpecificEye(x));
  };
};

setupBlinks();
blinkRandomEye();
let blinkInterval = setInterval(blinkRandomEye, 5000);

Right here is a Codepen hyperlink exhibiting an early demo of the eyes created throughout concepting. The eyes on the stay web site get angrier, although.

Hero Animation

The house hero includes a looping timeline which reveals numerous samples of images to match a pre-set search time period within the search bar. Moreover, the timeline wanted to be interruptible, hiding the pictures when the consumer clicks to kind their very own search time period and persevering with if the search bar is targeted out with no enter.

The GSAP timeline code itself right here is sort of easy, however one factor to notice is that we determined to have the timeline of pictures being revealed after which hidden be recreated every time on a setInterval, fairly than having one grasp timeline which controls the complete sequence. This made issues less complicated after we wanted to interrupt the pictures and conceal them rapidly because it permits us to keep away from having a grasp timeline competing with an interrupt timeline or tween making use of updates to the identical components.

const imageGroupCount = imageGroups.size;

const nextImages = () => {
  if (imageTl) imageTl.kill();
  if (clearTl) clearTl.kill();

  imageTl = gsap.timeline({
    defaults: {
      period: .7,
      stagger: perform (index, goal, record) {
        return index === 0 ? 0 : Math.ground(index / 3.01) * 0.3 + .3;
      }
    }
  });

  …

  prevIndex = nextIndex;
  nextIndex = (nextIndex + 1) % imageGroupCount
};

const hideImages = () => {
  if (imageTl) imageTl.kill();
  if (clearTl) clearTl.kill();

  clearTl = gsap.timeline({
    defaults: {
      period: .7
    }
  });

  …
};

Model Carousel

The model carousel has a easy marquee animation that was arrange with a looping GSAP timeline.

First, we clone the internal component to permit us to have a seamless loop.

const cloneElem = innerElem.cloneNode(true);
wrapperElem.appendChild(cloneElem);

Right here, we solely cloned it as soon as as a result of the internal component is giant sufficient to imagine one clone will cowl the display irrespective of the display measurement. In any other case, we must calculate what number of clones we’d like for the display to be crammed when the primary component has been translated totally to its full width.

Then we arrange a looping timeline, the place we translate each the internal component and its clone, 100% to their left. The period may be modified for various speeds, it might even be dynamic relying on the variety of components inside our marquee.

const loopTl = gsap.timeline({ repeat: -1, paused: true });

loopTl.fromTo([innerElem, cloneElem], {
  xPercent: 0,
}, {
  xPercent: -100,
  period: 10,
  ease: 'linear'
});

Lastly, we play and pause the timeline relying on if the part is in view, utilizing ScrollTrigger.

const scrollTrigger = ScrollTrigger.create({
  set off: wrapperElem,
  begin: 'prime backside',
  finish: 'backside prime',
  onToggle: (self) => {
    if (self.isActive) {
      loopTl.play();
    } else {
      loopTl.pause();
    }
  }
});

What’s Flim Part

The subsequent part, introducing Flim, had a wide range of interactions we would have liked to create.

First, the pictures wanted to seem over some textual content, after which go to their particular place inside a recreation of a Flim interface. This could be a difficult factor to perform whereas maintaining issues responsive. A very good resolution for this sort of animation is to make use of GSAP’s Flip.

Right here, we’ve the pictures styled throughout the first container, and magnificence them otherwise within the second container. We then add a ScrollTrigger to the textual content component, and when it comes into the middle of the display, we use Flip to save lots of the state, transfer the pictures from their preliminary container, to the second container, and animate the change of state.

ScrollTrigger.create({
  set off: textElem,
  begin: 'middle middle',
  finish: 'prime 10%',
  onEnter: () => {
    const state = Flip.getState(pictures);

    interfaceWrapper.append(...pictures);

    Flip.from(state, {
      period: 1,
      ease: 'power2.inOut',
    });
  }
});

The opposite portion of this part has falling shapes, which we’ll discuss quickly, that fall into a component that scrolls horizontally. To set off this animation we used ScrollTrigger, in addition to matchMedia, to maintain the common scroll on cell.

On desktop breakpoints we create a tween on a slider component that interprets it 100% of its width to its left. We add a scroll set off to that tween, that scrubs the animation and pins the wrapper into place in the course of this animation.

const mm = gsap.matchMedia();

mm.add('(min-width:992px)', () => {
  gsap.to(sliderElem, {
    xPercent: -100,
    ease: 'power2.inOut',
    scrollTrigger: {
      set off: triggerElem,
      begin: 'prime prime',
      finish: 'backside backside',
      pin: wrapperElem,
      scrub: 1,
    }
  });
});

Physics

An enormous problem for us was the physics sections, that are featured each on the house web page and the 404 web page. We are able to consider these sections as being constructed twice: we’ve the precise rendered scene, with DOM components, and the physics scene, which isn’t rendered and solely handles the way in which the weather ought to transfer.

The rendered scene is sort of easy. Every form is added inside a container, and is styled like another Webflow component. Relying on the form, it may be a easy div that’s styled, or an SVG. We add some helpful info to knowledge attributes like the kind of form it’s, to have the ability to use that when organising the physics scene.

For the physics facet, we first create a Rapier world with gravity.

const world = new RAPIER.World({ x: 0, y: -9.81 });

Then, we undergo every form throughout the container, and arrange their physics counterpart. We create a rigidBody, and place it the place we would like the form to begin. We then create a collider that matches the form. Rapier supplies some colliders for widespread shapes like circles (ball), rectangles (cuboid) or drugs (capsule). For different shapes just like the triangle, hexagon and numbers, we needed to create customized colliders with Rapier’s convexHull colliders. 

We use the DOM component’s measurement to arrange the colliders to match the precise form.

Right here’s an instance for a sq.:

const setupSquare = (startPosition, domElem) => {
  const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic();
  const rigidBody = world.createRigidBody(rigidBodyDesc);

  const x = startPosition.x * containerWidth / SIZE_RATIO;
  const width = domElem.clientWidth * 0.5 / SIZE_RATIO;
  const top = domElem.clientHeight * 0.5 / SIZE_RATIO;
  const y = startPosition.y * containerHeight / SIZE_RATIO + top;

  const colliderDesc = RAPIER.ColliderDesc.cuboid(width, top);
  const collider = world.createCollider(colliderDesc, rigidBody);

  return rigidBody;
}

Right here, startPosition is the world place we would like the form to begin at, containerWidth & containerHeight are the container DOM component’s measurement and domElem is the form’s DOM component. Lastly, SIZE_RATIO is a continuing (in our case, equal to 80) that permits us to transform pixel sizes to physics world sizes. You possibly can check out completely different values and see how that is helpful, however principally, if the weather are actually huge within the physics world, they are going to be very heavy and fall very quick.

Now we simply create some inflexible our bodies and colliders for the bottom and the partitions, in order that our shapes can keep away from simply falling without end. We are able to use the container DOM component’s measurement to correctly measurement our floor and partitions.

Establishing the bottom:

const groundRigidBodyType = RAPIER.RigidBodyDesc.mounted()
  .setTranslation(0, -0.1);
const floor = world.createRigidBody(groundRigidBodyType);

const groundColliderType = RAPIER.ColliderDesc.cuboid(
  containerWidth * 0.5 / SIZE_RATIO, 0.1
);

world.createCollider(groundColliderType, floor);

Now that the whole lot is ready up, we have to truly replace the physics on each body for issues to begin shifting. We are able to name requestAnimationFrame, and name Rapier’s world’s step perform, to replace the physics world on every body.

Our precise rendered scene doesn’t transfer but although. Like we mentioned, these are two completely different scenes, so we have to make the rendered scene transfer just like the physics one does. On every body, we have to undergo every form. For every form, we get the rigidBody’s translation and rotation, and we will use CSS remodel to maneuver and rotate the DOM component to match the physics component.

let frameID = null;

const updateShape = (rigidBody, domElem) => {
  // get place & rotation
  const place = rigidBody.translation();
  const rotation = rigidBody.rotation();

  // replace DOM component’s remodel
  domElem.model.remodel =
  `translate(-50%, 50%) translate3d(
    ${place.x * SIZE_RATIO}px,
    ${(-position.y) * SIZE_RATIO}px,
    0
  ) rotate(${-rotation}rad)`;
}

const replace = () => {
  // replace world
  world.step();

  // replace form remodel
  updateShape(shapeRigidBody, shapeDomElem);

  // request subsequent body
  frameID = requestAnimationFrame(replace);
}

We now must make it in order that the weather begin falling when the part comes into view, or else we’ll miss our good falling animation. We additionally don’t need our physics simulation to maintain working for no purpose after we can’t truly see the part. That is after we use GSAP’s ScrollTrigger, and arrange a set off on the part. After we enter the part, we begin the requestAnimationFrame loop and after we go away the part, we cease it.

const begin = () => {
  // begin body loop
  frameID = requestAnimationFrame(replace);
}

const cease = () => {
  cancelAnimationFrame(frameID);
  frameID = null;
}

ScrollTrigger.create({
  set off: '#intro-physics-container',
  endTrigger: '#intro-section',
  begin: 'top-=20% backside',
  finish: 'backside prime',
  onEnter: () => {
    begin();
  },
  onEnterBack: () => {
    begin();
  },
  onLeave: () => {
    cease();
  },
  onLeaveBack: () => {
    cease();
  },
});

One last item to bear in mind, is that we use the DOM component’s measurement to create all of our colliders. Which means we even have to scrub up the whole lot and create our colliders once more on resize, or our physics scene won’t match our rendered scene anymore.

Footer

A enjoyable little animation we like on this challenge is the dot of the “i” within the brand revealing a video like a door.

It’s truly fairly easy to do utilizing ScrollTrigger. With door being our black sq. component, and video being the video hidden behind it, we will arrange an animation on the door component’s rotation, and create a scroll set off to wash it with the scroll when it comes into view.

gsap.to(door, {
  rotationY: 125,
  scrollTrigger: {
    set off: door,
    begin: 'prime 90%',
    finish: 'max',
    scrub: true,
  },
  onStart: () => {
    video.play();
  }
});

The ScrollTrigger’s finish worth is ready to max as a result of these components are contained in the footer, so we would like the animation to finish when the consumer reaches the underside of the web page.

The door component must have a remodel perspective arrange for the rotation to seem like 3D. We additionally solely begin enjoying the video when the animation begins, as a result of it isn’t seen earlier than that anyway.

Conclusion

Flim was actually attention-grabbing and introduced lots of challenges. From a number of Lotties guiding the consumer all through the web page, to physics on some 2D components, there was so much to work on! Utilizing GSAP and Webflow was a strong pairing even earlier than the brand new GSAP options had been added, so we look ahead to giving these a strive within the close to future.

C’était un tremendous projet et on est très contents du résultat 🙂 🇫🇷

Our Stack

  • Webflow
  • GSAP for animation
  • Lenis for scroll
  • Lottie for After Results animation
  • Rapier for physics



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles