Instructions

Webflow  Template User  Guide
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. Floating Hover Icons
What it does: Displays a floating icon that smoothly follows the cursor while hovering over project and blog images. The icon fades and scales in on hover, tracks the cursor with a subtle easing effect, then fades out smoothly when leaving the image area.
Required HTML elements:
.project-image — container that triggers the project hover icon
.icon-project-wrap — floating icon displayed inside project images
.blog-image-wrap — container that triggers the blog hover icon
.icon-blog-wrap — floating icon displayed inside blog images
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  function initFloatingIcon(containerSelector, iconSelector) {

    document.querySelectorAll(containerSelector).forEach((container) => {

      const icon = container.querySelector(iconSelector);
      if (!icon) return;

      icon.style.pointerEvents = "none";

      let targetX = 0;
      let targetY = 0;

      let currentX = 0;
      let currentY = 0;

      let active = false;

      gsap.set(icon, {
        xPercent: -50,
        yPercent: -50,
        x: 0,
        y: 0,
        opacity: 0,
        scale: 0.7
      });

      container.addEventListener("mouseenter", (e) => {

        active = true;

        const rect = container.getBoundingClientRect();

        targetX = currentX = e.clientX - rect.left;
        targetY = currentY = e.clientY - rect.top;

        gsap.set(icon, {
          x: currentX,
          y: currentY
        });

        gsap.to(icon, {
          opacity: 1,
          scale: 1,
          duration: 0.35,
          ease: "power3.out",
          overwrite: true
        });

      });

      container.addEventListener("mousemove", (e) => {

        const rect = container.getBoundingClientRect();

        targetX = e.clientX - rect.left;
        targetY = e.clientY - rect.top;

      });

      container.addEventListener("mouseleave", () => {

        active = false;

        gsap.to(icon, {
          opacity: 0,
          scale: 0.7,
          duration: 0.25,
          ease: "power2.out",
          overwrite: true
        });

      });

      gsap.ticker.add(() => {

        if (!active) return;

        currentX += (targetX - currentX) * 0.18;
        currentY += (targetY - currentY) * 0.18;

        gsap.set(icon, {
          x: currentX,
          y: currentY
        });

      });

    });

  }

  initFloatingIcon(".project-image", ".icon-project-wrap");
  initFloatingIcon(".blog-image-wrap", ".icon-blog-wrap");

});
</script>
2. Lenis Smooth Scroll
What it does: Adds smooth scrolling to the website using Lenis on desktop devices. The scroll movement is softened for a smoother and more natural scrolling experience while maintaining full page height and footer accessibility.
<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;
    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>
3. Counter Number Animation
What it does: Animates numeric values when they enter the viewport using GSAP ScrollTrigger. The script automatically detects whole numbers and decimal values while preserving any prefixes or suffixes (such as %, +, $, or text labels). It works with any typography class by targeting the first child element inside each counter container.
Required HTML elements:
  • .counter-number-wrap the container that triggers the counter animation
  • First child element (e.g. .text-xl, .text-l, .text-xxl) — displays the animated number
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  gsap.registerPlugin(ScrollTrigger);

  document.querySelectorAll(".counter-number-wrap").forEach((counter) => {

    // Get the first child element (.text-xl, .text-l, .text-xxl, etc.)
    const number = counter.firstElementChild;
    if (!number) return;

    const text = number.textContent.trim();

    // Extract numeric value (supports decimals)
    const numeric = text.match(/[\d.]+/);
    if (!numeric) return;

    const finalValue = parseFloat(numeric[0]);

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

    // Preserve prefix/suffix
    const prefix = text.substring(0, text.indexOf(numeric[0]));
    const suffix = text.substring(text.indexOf(numeric[0]) + numeric[0].length);

    const obj = { value: 0 };

    gsap.to(obj, {
      value: finalValue,
      duration: 2,
      ease: "expo.out",

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

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

  });

});
</script>
4. Counter Number Animation
What it does: Enables a smooth horizontal swipe interaction for project cards on mobile devices. Users can swipe left or right to navigate between project cards, with distance and swipe velocity determining when the slider moves to the next or previous card.
Required HTML elements:
  • .projects-list-wrap main container that acts as the swipe viewport
  • .collection-list-wrapper-projects individual project card items that are moved horizontally
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // CARD SWIPE (MOBILE TOUCH SLIDER)
  // ==================================================

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

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

  // ── Config ──────────────────────────────────────────────
  const MOBILE_MIN_WIDTH = 360;
  const MOBILE_MAX_WIDTH = 479;
  const SWIPE_DISTANCE_RATIO = 0.2;
  const SWIPE_VELOCITY_THRESHOLD = 0.5;
  const DIRECTION_LOCK_THRESHOLD = 8;

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

  const cards = viewport
    ? Array.from(viewport.children).filter(function (el) {
        return el.classList.contains('collection-list-wrapper-projects');
      })
    : [];

  if (!viewport || cards.length === 0) {
    console.warn('[CardSwipe] .projects-list-wrap or .collection-list-wrapper-projects cards not found.');
    return;
  }

  // .swipe-indicator-wrap lives OUTSIDE .projects-list-wrap.
  const activeIndicator = viewport.parentElement
    ? viewport.parentElement.querySelector('.swipe-indicator-wrap .swipe-indicator-line')
    : null;

  if (!activeIndicator) {
    console.warn('[CardSwipe] .swipe-indicator-line not found outside .projects-list-wrap scope — swipe will still work, indicator will not update.');
  }

  const totalCards = cards.length;

  // ── State ───────────────────────────────────────────────
  let track = null;
  let cardWidth = 0;
  let gapPx = 0;
  let currentIndex = 0;
  let baseX = 0;
  let isSwipeActive = false;
  let isDragging = false;
  let directionLock = null;
  let startX = 0;
  let startY = 0;
  let startTime = 0;

  // ── Helpers ─────────────────────────────────────────────
  function checkMobileRange() {
    const w = window.innerWidth;
    return w >= MOBILE_MIN_WIDTH && w <= MOBILE_MAX_WIDTH;
  }

  function buildTrack() {
    track = document.createElement('div');
    track.style.display = 'flex';
    track.style.flexFlow = 'row';
    track.style.willChange = 'transform';

    viewport.style.position = viewport.style.position || 'relative';
    viewport.style.overflow = 'hidden';

    cards.forEach(function (card) {
      track.appendChild(card);
    });

    viewport.appendChild(track);
  }

  function teardownTrack() {
    if (!track) return;

    cards.forEach(function (card) {
      card.style.width = '';
      card.style.flex = '';
      viewport.appendChild(card);
    });

    track.remove();
    track = null;
  }

  function measure() {
    cardWidth = viewport.getBoundingClientRect().width;
    gapPx = parseFloat(window.getComputedStyle(viewport).columnGap) || 0;

    cards.forEach(function (card) {
      card.style.width = cardWidth + 'px';
      card.style.flex = '0 0 auto';
    });

    track.style.columnGap = gapPx + 'px';
  }

  function updateIndicator(index) {
    if (!activeIndicator) return;

    const percent = ((index + 1) / totalCards) * 100;

    gsap.to(activeIndicator, {
      width: percent + '%',
      duration: 0.3,
      ease: 'power2.out'
    });
  }

  function goToIndex(index, animate) {
    currentIndex = Math.max(0, Math.min(totalCards - 1, index));
    const targetX = -currentIndex * (cardWidth + gapPx);

    if (animate === false) {
      gsap.set(track, { x: targetX });
    } else {
      gsap.to(track, {
        x: targetX,
        duration: 0.4,
        ease: 'power3.out'
      });
    }

    baseX = targetX;
    updateIndicator(currentIndex);
  }

  // ── Touch handlers ──────────────────────────────────────
  function onTouchStart(e) {
    isDragging = true;
    directionLock = null;
    startX = e.touches[0].clientX;
    startY = e.touches[0].clientY;
    startTime = Date.now();
    gsap.killTweensOf(track);
  }

  function onTouchMove(e) {
    if (!isDragging) return;

    const currentX = e.touches[0].clientX;
    const currentY = e.touches[0].clientY;
    const deltaX = currentX - startX;
    const deltaY = currentY - startY;

    if (directionLock === null) {
      if (Math.abs(deltaX) > DIRECTION_LOCK_THRESHOLD || Math.abs(deltaY) > DIRECTION_LOCK_THRESHOLD) {
        directionLock = Math.abs(deltaX) > Math.abs(deltaY) ? 'x' : 'y';
      }
    }

    if (directionLock === 'y') {
      isDragging = false;
      return;
    }

    if (directionLock === 'x') {
      e.preventDefault();
      gsap.set(track, { x: baseX + deltaX });
    }
  }

  function onTouchEnd(e) {
    if (!isDragging || directionLock !== 'x') {
      isDragging = false;
      directionLock = null;
      return;
    }

    isDragging = false;

    const endX = (e.changedTouches && e.changedTouches[0].clientX) || startX;
    const deltaX = endX - startX;
    const elapsed = Math.max(Date.now() - startTime, 1);
    const velocity = Math.abs(deltaX) / elapsed;

    const passedDistance = Math.abs(deltaX) > cardWidth * SWIPE_DISTANCE_RATIO;
    const passedVelocity = velocity > SWIPE_VELOCITY_THRESHOLD;

    let nextIndex = currentIndex;

    if (passedDistance || passedVelocity) {
      nextIndex = deltaX < 0 ? currentIndex + 1 : currentIndex - 1;
    }

    goToIndex(nextIndex);
    directionLock = null;
  }

  // ── Enable / disable swipe mode ─────────────────────────
  function enableSwipe() {
    if (isSwipeActive) return;
    isSwipeActive = true;

    buildTrack();
    measure();
    goToIndex(0, false);

    track.addEventListener('touchstart', onTouchStart, { passive: true });
    track.addEventListener('touchmove', onTouchMove, { passive: false });
    track.addEventListener('touchend', onTouchEnd);
    track.addEventListener('touchcancel', onTouchEnd);
  }

  function disableSwipe() {
    if (!isSwipeActive) return;
    isSwipeActive = false;

    if (track) {
      track.removeEventListener('touchstart', onTouchStart);
      track.removeEventListener('touchmove', onTouchMove);
      track.removeEventListener('touchend', onTouchEnd);
      track.removeEventListener('touchcancel', onTouchEnd);
    }

    teardownTrack();
    currentIndex = 0;
  }

  function handleResize() {
    const nowMobile = checkMobileRange();

    if (nowMobile && !isSwipeActive) {
      enableSwipe();
    } else if (!nowMobile && isSwipeActive) {
      disableSwipe();
    } else if (nowMobile && isSwipeActive) {
      measure();
      goToIndex(currentIndex, false);
    }
  }

  // ── Init ────────────────────────────────────────────────
  if (checkMobileRange()) {
    enableSwipe();
  }

  window.addEventListener('resize', handleResize);

});
</script>