Instruction

Webflow Template User Guide
Abstract dark background with flowing wavy patterns created by gray dots varying in size and density.
GSAP Setup & Main Functions
All scripts go in Webflow Page Settings → Before </body> tag (or in Site Settings → Custom Code → Footer Code if global).

Webflow Site Settings → GSAP must have ScrollTriggrt enabled. Inertia optional (script has fallback).
1. PROJECTS MARQUEE
What it does: Creates an infinite horizontal marquee for project cards. The animation continuously loops and automatically pauses when a project card is hovered, then resumes when the cursor leaves.

‍Required HTML elements: .projects-list-wrap — marquee viewport. .w-dyn-items — Webflow Collection List track. .project-card-wrapper — project card used for hover pause.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // PROJECTS MARQUEE (INFINITE LOOP + HOVER PAUSE)
  // ==================================================

  // ── Guard against double-init ─────────────────────
  if (window._projectsMarqueeInit) return;
  window._projectsMarqueeInit = true;

  // ── GSAP availability check ───────────────────────
  if (typeof gsap === "undefined") {
    console.error("[ProjectsMarquee] GSAP not found.");
    return;
  }

  // ── DOM elements ──────────────────────────────────
  const viewport = document.querySelector(".projects-list-wrap");

  if (!viewport) {
    console.warn("[ProjectsMarquee] .projects-list-wrap not found.");
    return;
  }

  // Find the Webflow Collection List inside the projects wrapper.
  const track = viewport.querySelector(".w-dyn-items");

  if (!track) {
    console.warn("[ProjectsMarquee] Collection List not found.");
    return;
  }

  // Get original project items.
  const originalItems = Array.from(track.children);

  if (originalItems.length === 0) {
    console.warn("[ProjectsMarquee] No project items found.");
    return;
  }

  // ── Configuration ─────────────────────────────────
  const SPEED = 80;

  // ── Prepare viewport ──────────────────────────────
  viewport.style.overflow = "hidden";

  // ── Prepare marquee track ─────────────────────────
  track.style.display = "flex";
  track.style.flexWrap = "nowrap";
  track.style.width = "max-content";
  track.style.willChange = "transform";

  // Prevent Webflow Collection List items from shrinking.
  originalItems.forEach(function (item) {
    item.style.flex = "0 0 auto";
  });

  // ── Duplicate project items ───────────────────────
  originalItems.forEach(function (item) {

    const clone = item.cloneNode(true);

    clone.setAttribute(
      "data-marquee-clone",
      "true"
    );

    track.appendChild(clone);

  });

  // ── Calculate loop distance ───────────────────────
  let loopWidth = 0;
  let marqueeTween = null;

  function measure() {

    // The original items occupy the first half
    // of the duplicated track.
    loopWidth = 0;

    originalItems.forEach(function (item) {
      loopWidth += item.getBoundingClientRect().width;
    });

    // Include the gaps between collection items.
    const styles = window.getComputedStyle(track);

    const gap =
      parseFloat(styles.columnGap) ||
      parseFloat(styles.gap) ||
      0;

    loopWidth += gap * originalItems.length;

  }

  // ── Create marquee animation ──────────────────────
  function createMarquee() {

    if (marqueeTween) {
      marqueeTween.kill();
    }

    measure();

    if (!loopWidth) return;

    const duration = loopWidth / SPEED;

    marqueeTween = gsap.fromTo(
      track,
      {
        x: 0
      },
      {
        x: -loopWidth,
        duration: duration,
        ease: "none",
        repeat: -1
      }
    );

  }

  // ── Hover pause / resume ──────────────────────────
  function setupHoverPause() {

    const cards = track.querySelectorAll(
      ".project-card-wrapper"
    );

    cards.forEach(function (card) {

      card.addEventListener(
        "mouseenter",
        function () {

          if (marqueeTween) {
            marqueeTween.pause();
          }

        }
      );

      card.addEventListener(
        "mouseleave",
        function () {

          if (marqueeTween) {
            marqueeTween.resume();
          }

        }
      );

    });

  }

  // ── Initialise ────────────────────────────────────
  createMarquee();
  setupHoverPause();

  // ── Handle resize ─────────────────────────────────
  let resizeTimer;

  window.addEventListener("resize", function () {

    clearTimeout(resizeTimer);

    resizeTimer = setTimeout(function () {

      createMarquee();

    }, 200);

  });

});
</script>
2. CTA CIRCLES
What it does: Creates a subtle interactive cursor reaction for CTA circles. The circles gently move and tilt toward the cursor instead of directly following it.

‍Required HTML elements: .cta-section-wrapper — CTA interaction area. .circle-cta — animated CTA circles.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // CTA CIRCLES - SUBTLE CURSOR REACTION
  // ==================================================

  if (window._ctaCircleInit) return;
  window._ctaCircleInit = true;

  if (typeof gsap === "undefined") {
    console.error("[CTACircles] GSAP not found.");
    return;
  }

  const wrapper = document.querySelector(".cta-section-wrapper");
  const circles = document.querySelectorAll(".circle-cta");

  if (!wrapper || !circles.length) {
    console.warn("[CTACircles] Elements not found.");
    return;
  }

  let active = false;

  // ── Prepare circles ───────────────────────────────
  circles.forEach(function (circle) {

    gsap.set(circle, {
      transformPerspective: 1000,
      transformOrigin: "center center"
    });

  });

  // ── Smooth motion ─────────────────────────────────
  const moveX = [];
  const moveY = [];
  const rotateX = [];
  const rotateY = [];

  circles.forEach(function (circle, index) {

    const strength = 0.7 + (index * 0.12);

    moveX[index] = gsap.quickTo(circle, "x", {
      duration: 0.8,
      ease: "power3.out"
    });

    moveY[index] = gsap.quickTo(circle, "y", {
      duration: 0.8,
      ease: "power3.out"
    });

    rotateX[index] = gsap.quickTo(circle, "rotationX", {
      duration: 0.9,
      ease: "power3.out"
    });

    rotateY[index] = gsap.quickTo(circle, "rotationY", {
      duration: 0.9,
      ease: "power3.out"
    });

  });

  // ── Enter CTA ─────────────────────────────────────
  wrapper.addEventListener("mouseenter", function () {

    active = true;

  });

  // ── Leave CTA ─────────────────────────────────────
  wrapper.addEventListener("mouseleave", function () {

    active = false;

    circles.forEach(function (circle, index) {

      moveX[index](0);
      moveY[index](0);

      rotateX[index](0);
      rotateY[index](0);

    });

  });

  // ── Cursor tracking ───────────────────────────────
  window.addEventListener("mousemove", function (e) {

    if (!active) return;

    const rect = wrapper.getBoundingClientRect();

    // Cursor position relative to CTA
    // -1 = left/top
    //  0 = center
    //  1 = right/bottom
    const mouseX =
      ((e.clientX - rect.left) / rect.width) * 2 - 1;

    const mouseY =
      ((e.clientY - rect.top) / rect.height) * 2 - 1;

    circles.forEach(function (circle, index) {

      const strength =
        0.7 + (index * 0.12);

      // Very subtle movement
      const x =
        mouseX * 12 * strength;

      const y =
        mouseY * 12 * strength;

      // Stronger "looking" / tilting effect
      const rx =
        -mouseY * 16 * strength;

      const ry =
        mouseX * 16 * strength;

      moveX[index](x);
      moveY[index](y);

      rotateX[index](rx);
      rotateY[index](ry);

    });

  });

});
</script>
3. LENIS SMOOTH SCROLL (DESKTOP)
What it does: Adds smooth scrolling to the website on desktop devices using Lenis. The scroll behavior is tuned for a smooth and controlled feel while keeping the full page height and footer accessible.

‍Required HTML elements: No specific HTML elements or classes are required.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // LENIS SMOOTH SCROLL (DESKTOP)
  // ==================================================

  // ── Guard against double-init ──────────────────────────
  if (window._lenisScrollInit) return;
  window._lenisScrollInit = true;

  // desktop only
  const isDesktop = window.matchMedia("(min-width: 992px)").matches;
  if (!isDesktop) return;

  const lenisScript = document.createElement("script");
  lenisScript.src = "https://unpkg.com/lenis@1.3.21/dist/lenis.min.js";

  lenisScript.onload = function () {

    if (typeof Lenis === "undefined") {
      console.error("[LenisScroll] Lenis failed to load.");
      return;
    }

    const lenis = new Lenis({
      lerp: 0.07,          // the smaller, the smoother
      smoothWheel: true,
      wheelMultiplier: 0.8,
      autoRaf: false,
      // Force document-level scroll measurement. Without this, an
      // overflow:hidden element earlier in the DOM (e.g. a sticky
      // section wrapper) can cause Lenis to under-measure total
      // scrollHeight, cutting off content at the end of the page
      // (footer) even though native scroll renders it fine.
      wrapper: window,
      content: document.documentElement
    });

    function raf(time) {
      lenis.raf(time);
      requestAnimationFrame(raf);
    }

    requestAnimationFrame(raf);

    // so it can be called from other scripts
    window.lenis = lenis;

    // ── Fix: keep scroll limit in sync with actual document height ──
    // ResizeObserver on body catches any layout change that affects
    // total scrollHeight, including the case where an overflow:hidden
    // sticky wrapper earlier in the DOM causes Lenis to under-measure
    // the page on first init. This covers font swaps, late layout
    // shifts, and the sticky-wrap case together, instead of relying
    // on a fixed timeout guess.
    if (typeof ResizeObserver !== "undefined") {
      const resizeObserver = new ResizeObserver(function () {
        lenis.resize();
      });
      resizeObserver.observe(document.body);
    } else {
      // Fallback for browsers without ResizeObserver support
      setTimeout(function () {
        lenis.resize();
      }, 500);
    }

    // ── Fix: keep scroll limit correct on viewport resize ──
    window.addEventListener("resize", function () {
      lenis.resize();
    });

  };

  document.body.appendChild(lenisScript);

});
</script>
4. COUNTER NUMBER ANIMATION
What it does: Animates numbers from 0 to their final value when the counter enters the viewport. Supports whole numbers, decimals, prefixes, and suffixes.

‍Required HTML elements: .counter-number — counter wrapper and ScrollTrigger target. The child text element can use any typography class such as .text-xxl, .text-xl, .text-lg, .text-md, or .text-sm.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // COUNTER NUMBER ANIMATION
  // ==================================================

  if (window._counterNumberInit) return;
  window._counterNumberInit = true;

  if (typeof gsap === "undefined") {
    console.error("[CounterNumber] GSAP not found.");
    return;
  }

  gsap.registerPlugin(ScrollTrigger);

  document.querySelectorAll(".counter-number").forEach(function (counter) {

    // Find the first text element inside the counter.
    // Works with text-xxl, text-xl, text-lg, text-md, text-sm, etc.
    const number = counter.firstElementChild;

    if (!number) return;

    const text = number.textContent.trim();

    // Extract numeric value
    const numericMatch = text.match(/[\d.]+/);

    if (!numericMatch) return;

    const numericText = numericMatch[0];
    const finalValue = parseFloat(numericText);

    // Count decimal places
    const decimals =
      (numericText.split(".")[1] || "").length;

    // Preserve prefix and suffix
    const numberStart =
      text.indexOf(numericText);

    const prefix =
      text.substring(0, numberStart);

    const suffix =
      text.substring(
        numberStart + numericText.length
      );

    // Animated value
    const obj = {
      value: 0
    };

    gsap.to(obj, {

      value: finalValue,

      duration: 2.8,

      ease: "power3.out",

      scrollTrigger: {
        trigger: counter,
        start: "top 88%",
        once: true
      },

      onUpdate: function () {

        number.textContent =
          prefix +
          obj.value.toFixed(decimals) +
          suffix;

      }

    });

  });

});
</script>