GSAP Guide

Take advantage of beautiful design and create a website your business deserves.

📐 Marquee Animation

To make the marquee work, the layout needs to be structured with the correct attributes:

  • The outer wrapper must have:
    tr-marquee-element="component"
  • The content that scrolls must be wrapped with:
    tr-marquee-element="panel"

Inside the Webflow Designer, it looks like this:

⚙️ Customization Options

Here are the available attributes you can use to control the marquee animation behavior:

  • tr-marquee-speed (Number)
    Sets the speed of the animation. The higher the number, the slower it moves.
    Default: 100
  • tr-marquee-vertical (Boolean)
    Makes the marquee scroll vertically instead of horizontally when set to true.
  • tr-marquee-reverse (Boolean)
    Reverses the scroll direction when set to true.
  • tr-marquee-scrolldirection (Boolean)
    Syncs the direction of the marquee with the scroll direction.
    When you scroll down, it moves one way—scroll up, and it reverses.
    Use true to enable.
  • tr-marquee-scrollscrub (Boolean)
    Enables a "scrubbing" effect based on your scroll speed.
    Faster scroll = faster marquee, giving a more dynamic feel.
<script>
// MARQUEE POWER-UP
window.addEventListener("DOMContentLoaded", (event) => {
  // attribute value checker
  function attr(defaultVal, attrVal) {
    const defaultValType = typeof defaultVal;
    if (typeof attrVal !== "string" || attrVal.trim() === "") return defaultVal;
    if (attrVal === "true" && defaultValType === "boolean") return true;
    if (attrVal === "false" && defaultValType === "boolean") return false;
    if (isNaN(attrVal) && defaultValType === "string") return attrVal;
    if (!isNaN(attrVal) && defaultValType === "number") return +attrVal;
    return defaultVal;
  }

  // marquee component
  $("[tr-marquee-element='component']").each(function (index) {
    let componentEl = $(this),
      panelEl = componentEl.find("[marquee-element='panel']");
    let speedSetting = attr(100, componentEl.attr("marquee-speed")),
      verticalSetting = attr(false, componentEl.attr("marquee-vertical")),
      reverseSetting = attr(false, componentEl.attr("marquee-reverse")),
      scrollDirectionSetting = attr(false, componentEl.attr("marquee-scrolldirection")),
      scrollScrubSetting = attr(false, componentEl.attr("marquee-scrollscrub")),
      moveDistanceSetting = reverseSetting ? 100 : -100,
      timeScaleSetting = 1;

    let marqueeTimeline = gsap.timeline({ repeat: -1, onReverseComplete: () => marqueeTimeline.progress(1) });

    if (verticalSetting) {
      speedSetting = panelEl.first().height() / speedSetting;
      marqueeTimeline.fromTo(panelEl, { yPercent: 0 }, { yPercent: moveDistanceSetting, ease: "none", duration: speedSetting });
    } else {
      speedSetting = panelEl.first().width() / speedSetting;
      marqueeTimeline.fromTo(panelEl, { xPercent: 0 }, { xPercent: moveDistanceSetting, ease: "none", duration: speedSetting });
    }

    let scrubObject = { value: 1 };

    ScrollTrigger.create({
      trigger: "body",
      start: "top top",
      end: "bottom bottom",
      onUpdate: (self) => {
        if (scrollDirectionSetting) {
          timeScaleSetting = self.direction;
          marqueeTimeline.timeScale(self.direction);
        }
        if (scrollScrubSetting) {
          let v = self.getVelocity() * 0.006;
          v = gsap.utils.clamp(-60, 60, v);
          let scrubTimeline = gsap.timeline({ onUpdate: () => marqueeTimeline.timeScale(scrubObject.value) });
          scrubTimeline.fromTo(scrubObject, { value: v }, { value: timeScaleSetting, duration: 0.5 });
        }
      }
    });
  });
});

</script>
⚙️ Text Animation Custom Attributes

You can control how each text block animates by using these attributes:

  • data-animate="text"
    Enables the GSAP-based text animation on the element.
  • data-type="chars"
    Animation splits text by characters.
    Other options: "words" or "lines".
  • data-duration="1.2"
    Controls how long the animation lasts (in seconds).
  • data-delay="2"
    Adds a delay (in seconds) before the animation starts.
  • data-start="hidden"
    Keeps the text hidden until animation triggers.
    (Handled by GSAP with autoAlpha: 1 at start.)
<script>
{
  let lenis;

  const initScroll = () => {
    lenis = new Lenis({});
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add((time) => lenis.raf(time * 1000));
    gsap.ticker.lagSmoothing(0);
  };

  function initGsapGlobal() {
    /** Do everything that needs to happen
     *  before triggering all
     * the gsap animations */

    initScroll();

    // match reduced motion media
    // const media = gsap.matchMedia();

    /** Send a custom
     *  event to all your
     * gsap animations
     * to start them */

    const sendGsapEvent = () => {
      window.dispatchEvent(
        new CustomEvent("GSAPReady", {
          detail: {
            lenis,
          },
        })
      );
    };

    // Check if fonts are already loaded
    if (document.fonts.status === "loaded") {
      sendGsapEvent();
    } else {
      document.fonts.ready.then(() => {
        sendGsapEvent();
      });
    }

    /** We need specific handling because the
     * grid/list changes the scroll height of the whole container
     */

    let resizeTimeout;
    const onResize = () => {
      clearTimeout(resizeTimeout);
      resizeTimeout = setTimeout(() => {
        ScrollTrigger.refresh();
      }, 50);
    };

    window.addEventListener("resize", () => onResize());
    const resizeObserver = new ResizeObserver((entries) => onResize());
    resizeObserver.observe(document.body);

    queueMicrotask(() => {
      gsap.to("[data-start='hidden']", {
        autoAlpha: 1,
        duration: 0.1,
        delay: 0.2,
      });
    });
  }

  // this only for dev
  const documentReady =
    document.readyState === "complete" || document.readyState === "interactive";

  if (documentReady) {
    initGsapGlobal();
  } else {
    addEventListener("DOMContentLoaded", (event) => initGsapGlobal());
  }
}
</script>
<script>
{
/** Utility Functions */
const split = (text, type = "chars") => {
  text.setAttribute("aria-label", text.textContent);
  const splits = new SplitText(text, { type });
  splits[type].forEach((char) => char.setAttribute("aria-hidden", "true"));
  return splits[type];
};

const getParams = (item) => {
  const { duration, delay, stagger, ease, type } = item.dataset;
  const params = {
    duration: 1.2,
    delay: 0,
    type: "chars",
  };
  if (duration) params.duration = parseFloat(duration);
  if (delay) params.delay = parseFloat(delay);
  if (type && (type === "chars" || type === "words" || type === "lines"))
    params.type = type;
  return params;
};

const baseAnimation = (item) => {
  const params = getParams(item);
  return {
    duration: params.duration,
    delay: params.delay,
    ease: "expo.out",
    //   stagger each item so the letters come in one after the other
    stagger: {
      each: 0.04,
      from: "start",
    },
    scrollTrigger: {
      trigger: item,
      // use scrolltrigger to trigger on enter and reset on exit
      toggleActions: "play none none none",
    },
  };
};

/** (1) Split Reveal Animation */
function createSplitRevealAnimation(item) {
  const params = getParams(item); // 1. get params from attributes
  const text = split(item, params.type); // 2. split the text
  
  // 3. set container to overflow hidden
  item.style.overflow = "hidden";
  
  // 4. For words and lines, create proper mask containers
  if (params.type === "words" || params.type === "lines") {
    text.forEach((element) => {
      // Create inner wrapper for animation
      const inner = document.createElement('div');
      inner.innerHTML = element.innerHTML;
      inner.style.display = 'inline-block';
      
      // Set up outer wrapper as mask
      element.style.display = 'inline-block';
      element.style.overflow = 'hidden';
      element.style.verticalAlign = 'top';
      element.innerHTML = '';
      element.appendChild(inner);
    });
    
    // Animate the inner elements instead
    const innerElements = text.map(el => el.children[0]);
    return gsap.fromTo(
      innerElements,
      { yPercent: 120 },
      {
        ...baseAnimation(item),
        yPercent: 0,
      }
    );
  }
  
  // 5. For chars, animate normally
  return gsap.fromTo(
    text,
    { yPercent: 120 },
    {
      ...baseAnimation(item),
      yPercent: 0,
    }
  );
}

function createScrambledAnimation(item) {
  const text = item.textContent;
  const randomText = Array(text.length)
    .fill()
    .map(() => String.fromCharCode(Math.floor(Math.random() * (90 - 65) + 65)))
    .join("")
    .toLowerCase();
  return gsap.fromTo(
    item.children[0],
    {
      scrambleText: randomText,
    },
    {
      ...baseAnimation(item),
      scrambleText: text,
      chars: text,
    }
  );
}

/** Main Text Effect Wrapper */
addEventListener("GSAPReady", (event) => {
  const texts = [...document.querySelectorAll('[data-animate="text"]')];
  texts.forEach((item) => {
    if (item.dataset.type === "chars") createSplitRevealAnimation(item);
    else if (item.dataset.type === "words") createSplitRevealAnimation(item);
    else if (item.dataset.type === "scramble") createScrambledAnimation(item);
  });
});
}
</script>