Complex GSAP Timelines and Function-based Values

Let's rebuild the loading animation from the Awwwards Site of the Day (SOTD) for Oct 13, 2022 - Spotify Astrology Club. This build will advance your knowledge of more challenging concepts like:

  • Clip path for a solid circle fill.
  • Element stacking context (when we can't use z-index)
  • Relationship between absolute position and height and how it affects animation direction
  • Complex, long, and chained GSAP¬†timelines
  • Passing function-based values to GSAP properties we will animate
  • Forcing one animation (loading time) to last the same duration as a second, parallel animation
  • Animating a counter with GSAP's snap property
Watch the Tutorial on YouTubeGet the Project Cloneable

Inside <head> tag

<script defer src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.11.4/gsap.min.js"></script>
<script defer src="https://unpkg.com/split-type"></script>

<style>
.char, .is-a-lie {
	/* prevent flash of unstyled content */
	visibility: hidden
}
</style>

Inside </body> tag

<script>
window.Webflow ||= [];
window.Webflow.push(() => {
  // split the 'Astrology' heading into character spans
  const splitHeading = new SplitType('.h1');
  
  // define a master timeline
  const master = gsap.timeline();
  master.add(counter(loader().totalDuration())).add(header());

  function counter(duration) {
    //
    return gsap.to('#counter-num', {
      innerText: 100,
      snap: 'innerText', // snaps to nearest integer
      duration,
      ease: 'power4.out',
    });
  }

  function loader() {
    const tlLoader = gsap
      .timeline({
        onComplete: () => {
          gsap.set('.loader', { visibility: 'hidden' });
        },
      })
      // circle moon translate up/right with pause
      .to('.circle-moon-move', {
        xPercent: 20,
        yPercent: -20,
        duration: 1,
        ease: 'power4.out',
      })
      .to('.circle-moon-move', {
        xPercent: 100,
        yPercent: -100,
        duration: 1,
        ease: 'power4.out',
      })
      // animate orange panel from bottom
      .to(
        '.panel-orange',
        {
          height: '100%',
          duration: 1,
          ease: 'power4.out',
        },
        '<+0.1' // 0.1 seconds after the start of the previous animation
      )
      // fill circle with blue, with pause at 50%
      .to(
        '.circle-fill-blue',
        {
          height: '50%',
          duration: 1,
          ease: 'power4.out',
        },
        '<' // starts at same time as the start of the previous animation
      )
      .to('.circle-fill-blue', {
        height: '100%',
        duration: 1,
        ease: 'power4.out',
      })
      // animate blue panel from bottom just as circle is filled with blue
      .to(
        '.panel-blue',
        {
          height: '100%',
          duration: 1,
          ease: 'power2.out',
        },
        '>-0.7' // start 0.7 second before the end of the previous animation
      )
      // fill circle with yellow just as its blue fill finsihed and blue panel starts
      .to(
        '.circle-fill-yellow',
        {
          height: '100%',
          duration: 1,
          ease: 'power4.out',
        },
        '<'
      )
      // expanding circles
      // "breathe in" with '.circle-mask' which has the clip-path applied
      .to('.circle-mask', {
        scale: 0.9,
      })
      // scale up '.circle-mask' (clip path)
      .to('.circle-mask', {
        scale: 3,
        duration: 1,
      })
      // scale up final orange circle, which also overlays our loading percent number
      .to(
        '.circle-orange-final',
        {
          scale: 2, // sacling to size of circle-mask, which is also scaling.
          duration: 1.5,
        },
        '<'
      );

    return tlLoader;
  }

  function header() {
    const headerTimeline = gsap.timeline();

    headerTimeline
      .fromTo(
        splitHeading.chars,
        {
          opacity: 0,
          // starting value determined by index and multiplier value so that each character
          // is further down/right and rotated than the last.
          xPercent: (index) => {
            return (index + 1) * 20;
          },
          yPercent: (index) => {
            return (index + 1) * 30;
          },
          rotateZ: (index) => {
            return (index + 1) * 9;
          },
        },
        { autoAlpha: 1, xPercent: 0, yPercent: 0, rotateZ: 0, duration: 0.5 }
      )
      .fromTo(
        '.is-a-lie',
        {
          opacity: 0,
        },
        { autoAlpha: 1, duration: 1, delay: 1 }
      );

    return headerTimeline;
  }
});
</script>