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. Custom Cursor
What it does: Smooth-follow cursor using GSAP ticker. Exposes window._cursorPause() and window._cursorResume() to hide/show the pointer ring.
Required HTML elements:
.cursor-core — outer cursor element (animated container)
.cursor-pointer — inner visual (the actual circle, can be hidden via pause/resume)
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // MAIN CURSOR
  // ==================================================

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

  var cursor = document.querySelector(".cursor-core");
  var pointer = document.querySelector(".cursor-pointer");

  if (!cursor) return;

  var mouseX = -100;
  var mouseY = -100;

  var x = -100;
  var y = -100;

  var paused = false;

  // initial position
  gsap.set(cursor, {
    xPercent: -50,
    yPercent: -50,
    x: x,
    y: y
  });

  // smooth follow
  gsap.ticker.add(function () {

    x += (mouseX - x) * 0.15;
    y += (mouseY - y) * 0.15;

    gsap.set(cursor, {
      x: x,
      y: y
    });

  });

  // track mouse
  window.addEventListener("mousemove", function (e) {

    mouseX = e.clientX;
    mouseY = e.clientY;

  });

  // =========================================
  // HIDE POINTER ONLY
  // =========================================

  window._cursorPause = function () {

    paused = true;

    if (!pointer) return;

    gsap.to(pointer, {
      opacity: 0,
      duration: 0.2,
      overwrite: true
    });

  };

  // =========================================
  // SHOW POINTER AGAIN
  // =========================================

  window._cursorResume = function () {

    paused = false;

    if (!pointer) return;

    gsap.to(pointer, {
      opacity: 1,
      duration: 0.2,
      overwrite: true
    });

  };

});
</script>
2. Partner Logo Marquee
Usage notes:
  • Wrap your logo items in a container with class partner-logo-wrap and overflow: hidden set in Webflow Designer.
  • Place logo elements as direct children — the script auto-builds an inner track, duplicates the set for a seamless loop, and preserves each logo's Designer-applied gap/width.
  • Hover over the wrap pauses the marquee; leaving resumes it.
  • Adjust speed via the duration: distance / 50 divisor (higher = slower).
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ── Guard against double-init ──────────────────────────
  // Set to true only after setup succeeds further down, so a failed/partial
  // run (e.g. GSAP not yet loaded) can retry on the next call instead of
  // permanently blocking the script.
  if (window._partnerMarqueeInit) return;

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

  // ── DOM check ──────────────────────────────────────────
  // .partner-logo-wrap is the VIEWPORT: it already has overflow:hidden in
  // its own CSS. It must stay static — it is the clipping window, not the
  // element that moves. A separate inner track div is created below to
  // hold the logos and be the thing GSAP translates.
  const viewport = document.querySelector(".partner-logo-wrap");
  if (!viewport) {
    console.warn("[PartnerMarquee] .partner-logo-wrap not found.");
    return;
  }

  const TRACK_CLASS = "partner-marquee-track";
  let track = viewport.querySelector(":scope > ." + TRACK_CLASS);

  if (!track) {
    // First run: build the track from the original .logo-wrap children.
    const originalChildren = Array.from(viewport.children);

    if (originalChildren.length === 0) {
      console.warn("[PartnerMarquee] .partner-logo-wrap has no logo children.");
      return;
    }

    // Read the gap Webflow already applies to .partner-logo-wrap so the
    // track reproduces the same spacing between logos.
    const computedGap = getComputedStyle(viewport).columnGap;

    // Lock each logo's natural rendered width BEFORE moving it into a new
    // flex context, so flex-basis recalculation can't shrink it.
    originalChildren.forEach((child) => {
      const naturalWidth = child.getBoundingClientRect().width;
      child.style.flex = "0 0 auto";
      child.style.width = naturalWidth + "px";
    });

    // Build the track div that will actually be translated.
    track = document.createElement("div");
    track.className = TRACK_CLASS;
    track.style.display = "flex";
    track.style.flexWrap = "nowrap";
    track.style.alignItems = "center";
    track.style.columnGap = computedGap;
    track.style.width = "max-content";

    // Move the original logos into the track (appendChild moves existing
    // nodes, it does not clone them).
    originalChildren.forEach((child) => track.appendChild(child));

    // Clone the set once more inside the track so the loop has no seam.
    originalChildren.forEach((child) => {
      const clone = child.cloneNode(true);
      clone.setAttribute("aria-hidden", "true");
      track.appendChild(clone);
    });

    viewport.appendChild(track);
  }

  let marqueeTween;

  // ── Build (or rebuild) the seamless loop ───────────────
  function buildMarquee() {
    if (marqueeTween) {
      marqueeTween.kill();
      gsap.set(track, { x: 0 });
    }

    // Half of the track's total width = width of one full logo set.
    const distance = track.scrollWidth / 2;

    marqueeTween = gsap.to(track, {
      x: -distance,
      duration: distance / 50, // adjust divisor to tune speed (px per second)
      ease: "none",
      repeat: -1,
    });
  }

  buildMarquee();

  // Setup succeeded — now safe to mark as initialized.
  window._partnerMarqueeInit = true;

  // ── Hover pause / resume ───────────────────────────────
  // Listeners on the viewport (the visible, static, hoverable box).
  viewport.addEventListener("mouseenter", () => {
    if (marqueeTween) marqueeTween.pause();
  });

  viewport.addEventListener("mouseleave", () => {
    if (marqueeTween) marqueeTween.resume();
  });

  // ── Rebuild on resize (debounced) so distance stays accurate ──
  let resizeTimeout;
  window.addEventListener("resize", () => {
    clearTimeout(resizeTimeout);
    resizeTimeout = setTimeout(buildMarquee, 250);
  });

});
</script>
3. Counter Animation
Usage notes:
  • Wrap each counter in .counter-wrap, with the number itself inside a child .counter-number (plain digits, e.g. 500 or 500+).
  • Requires GSAP + ScrollTrigger loaded.
  • Counts up automatically: immediately if already in viewport on load, otherwise triggers when scrolled to 85% from top.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

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

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

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

  gsap.registerPlugin(ScrollTrigger);

  const counters = document.querySelectorAll(".counter-wrap");

  if (!counters.length) {
    console.warn("[Counter] No .counter-wrap elements found.");
    return;
  }

  function isInViewport(el) {
    const rect = el.getBoundingClientRect();
    return rect.top < window.innerHeight && rect.bottom > 0;
  }

  function runCounter(numberEl, targetNumber, index) {
    const obj = { val: 0 };
    gsap.to(obj, {
      val: targetNumber,
      duration: 2,
      delay: index * 0.15,
      ease: "power2.out",
      onUpdate: function () {
        numberEl.textContent = Math.round(obj.val);
      },
      onComplete: function () {
        numberEl.textContent = targetNumber;
      }
    });
  }

  // Read all target values BEFORE modifying anything in the DOM
  const items = [];

  counters.forEach(function (wrap, index) {
    const numberEl = wrap.querySelector(".counter-number");

    if (!numberEl) {
      console.warn("[Counter] .counter-number not found at index:", index);
      return;
    }

    const targetNumber = parseInt(numberEl.textContent.trim().replace(/\D/g, ""), 10);

    if (isNaN(targetNumber) || targetNumber === 0) {
      console.warn("[Counter] Invalid value at index:", index, "| value:", numberEl.textContent);
      return;
    }

    // Store reference and target, then reset display to 0
    items.push({ wrap, numberEl, targetNumber, index });
    numberEl.textContent = "0";
  });

  // Set up animations only after all targets are stored
  items.forEach(function ({ wrap, numberEl, targetNumber, index }) {
    if (isInViewport(wrap)) {
      setTimeout(function () {
        runCounter(numberEl, targetNumber, index);
      }, 100);
    } else {
      ScrollTrigger.create({
        trigger: wrap,
        start: "top 85%",
        once: true,
        onEnter: function () {
          runCounter(numberEl, targetNumber, index);
        }
      });
    }
  });

});
</script>
4. Case Study Hover Cursor
Usage notes:
  • Add link-card-case to each hoverable card, and hover-case-studies to the badge/label that should follow the cursor inside it.
  • Desktop only — auto-disabled at max-width 912px.
  • Depends on Script 1's _cursorPause/_cursorResume to hide the main cursor while hovering.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

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

  // Desktop-only gate — skip entirely on tablet/mobile (360px–912px)
  if (window.matchMedia("(max-width: 912px)").matches) return;

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

  var cards = document.querySelectorAll(".link-card-case");

  if (!cards.length) {
    console.warn("[CaseHoverCursor] .link-card-case not found.");
    return;
  }

  cards.forEach(function (card) {

    var badge = card.querySelector(".hover-case-studies");
    if (!badge) return;

    var quickX = gsap.quickTo(badge, "x", { duration: 0.4, ease: "power3" });
    var quickY = gsap.quickTo(badge, "y", { duration: 0.4, ease: "power3" });

    gsap.set(badge, { opacity: 0, scale: 0.6 });

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

      if (typeof window._cursorPause === "function") {
        window._cursorPause();
      }

      // Do NOT use overwrite: true here — it kills the quickTo x/y tweens
      // on the same target because overwrite:true clears ALL tweens on
      // the target regardless of property. Default "auto" only clears
      // conflicting properties (opacity/scale), leaving x/y intact.
      gsap.to(badge, {
        opacity: 1,
        scale: 1,
        duration: 0.3,
        ease: "power2.out"
      });

    });

    card.addEventListener("mousemove", function (e) {

      var rect = card.getBoundingClientRect();
      var relX = e.clientX - rect.left;
      var relY = e.clientY - rect.top;

      quickX(relX);
      quickY(relY);

    });

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

      if (typeof window._cursorResume === "function") {
        window._cursorResume();
      }

      gsap.to(badge, {
        opacity: 0,
        scale: 0.6,
        duration: 0.3,
        ease: "power2.in"
      });

    });

  });

});
</script>
Team Card Slider
Usage notes:
  • Container needs class team-member-list; each slide is a child with class team-card.
  • Requires prev/next arrow buttons inside .all-arrow-team .arrow-team-wrap (first = prev, last = next).
  • Mobile only — active at max-width 479px; inactive elsewhere.
  • Infinite loop navigation via button clicks only (no swipe).
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // Guard: prevent double-init
  if (window._teamCardSliderInit) return;
  window._teamCardSliderInit = true;

  // Guard: only active on mobile (max 479px)
  if (!window.matchMedia("(max-width: 479px)").matches) return;

  // GSAP availability check
  if (typeof gsap === "undefined") {
    console.error("[TeamCardSlider] GSAP not found. Make sure GSAP is loaded before this script.");
    return;
  }

  // DOM check
  var wrap = document.querySelector(".team-member-list");
  if (!wrap) {
    console.warn("[TeamCardSlider] .team-member-list not found.");
    return;
  }

  var cards = Array.from(wrap.querySelectorAll(".team-card"));
  if (cards.length === 0) {
    console.warn("[TeamCardSlider] No .team-card found inside .team-member-list.");
    return;
  }

  var btnPrev = document.querySelector(".all-arrow-team .arrow-team-wrap:first-child");
  var btnNext = document.querySelector(".all-arrow-team .arrow-team-wrap:last-child");

  if (!btnPrev || !btnNext) {
    console.warn("[TeamCardSlider] Arrow buttons not found.");
    return;
  }

  // Setup: overflow hidden on wrap so off-frame cards are invisible
  gsap.set(wrap, {
    overflow: "hidden",
    position: "relative"
  });

  var totalCards = cards.length;
  var currentIndex = 0;
  var isAnimating = false;
  var containerWidth = wrap.offsetWidth;

  // Set all cards to absolute positioning, stacked at the same origin.
  // Only the active card is at x:0. All others are pushed off-frame (x: containerWidth).
  function initPositions() {
    containerWidth = wrap.offsetWidth;
    cards.forEach(function (card, i) {
      gsap.set(card, {
        position: "absolute",
        top: 0,
        left: 0,
        width: containerWidth,
        flexShrink: 0,
        // All inactive cards start off-frame to the right
        x: i === 0 ? 0 : containerWidth
      });
    });
    // Give wrap an explicit height so it doesn't collapse after cards go absolute
    gsap.set(wrap, { height: cards[0].offsetHeight });
  }

  initPositions();

  // Navigation function
  function goTo(newIndex) {
    if (isAnimating || newIndex === currentIndex) return;
    isAnimating = true;

    containerWidth = wrap.offsetWidth;

    // Determine slide direction:
    // +1 = sliding left (next), -1 = sliding right (prev)
    var direction;
    if (newIndex > currentIndex) {
      direction = 1;
    } else {
      direction = -1;
    }

    // Detect wraparound cases
    if (currentIndex === totalCards - 1 && newIndex === 0) direction = 1;
    if (currentIndex === 0 && newIndex === totalCards - 1) direction = -1;

    var cardOut = cards[currentIndex];
    var cardIn  = cards[newIndex];

    // Place incoming card just off-frame in the correct direction
    gsap.set(cardIn, { x: direction * containerWidth });

    var tl = gsap.timeline({
      onComplete: function () {
        // Push the outgoing card fully off-frame and keep it there.
        // This is the fix: card lama dikirim jauh ke luar frame setelah animasi selesai.
        gsap.set(cardOut, { x: -direction * containerWidth });
        isAnimating = false;
        currentIndex = newIndex;
      }
    });

    // Outgoing card slides out
    tl.to(cardOut, {
      x: -direction * containerWidth,
      duration: 0.5,
      ease: "power2.inOut"
    }, 0);

    // Incoming card slides in
    tl.to(cardIn, {
      x: 0,
      duration: 0.5,
      ease: "power2.inOut"
    }, 0);
  }

  // Button listeners
  btnNext.addEventListener("click", function () {
    var nextIndex = (currentIndex + 1) % totalCards;
    goTo(nextIndex);
  });

  btnPrev.addEventListener("click", function () {
    var prevIndex = (currentIndex - 1 + totalCards) % totalCards;
    goTo(prevIndex);
  });

  // Resize handler: recalculate on orientation change
  window.addEventListener("resize", function () {
    if (!window.matchMedia("(max-width: 479px)").matches) return;
    containerWidth = wrap.offsetWidth;
    // Re-pin active card at x:0, push all others off-frame
    cards.forEach(function (card, i) {
      gsap.set(card, {
        width: containerWidth,
        x: i === currentIndex ? 0 : containerWidth
      });
    });
    gsap.set(wrap, { height: cards[currentIndex].offsetHeight });
  });

});
</script>
Case Studies Card Swipe
Usage notes:
  • Container: list-card-cms with overflow: hidden; each CMS card: list-card-case.
  • Add a separate progress indicator element outside the CMS list, structured as .case-card > .swipe-indicator-wrap .swipe-indicator (must NOT be inside the CMS collection list, or it gets pulled into the draggable track).
  • Mobile only — active between 360–440px viewport width; auto disables/enables on resize.
  • Touch swipe/drag only; step-based (one card per swipe), driven by distance or flick velocity.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ── 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 = 440;
  const SWIPE_DISTANCE_RATIO = 0.2;   // % of card width needed to trigger a step
  const SWIPE_VELOCITY_THRESHOLD = 0.5; // px/ms, for fast flicks below the distance ratio
  const DIRECTION_LOCK_THRESHOLD = 8; // px, before we decide horizontal vs vertical intent

  // ── DOM check ───────────────────────────────────────────
  // .list-card-cms has overflow:hidden (by design), so it acts as the
  // fixed "viewport". The individual CMS-repeated cards are its direct
  // children before this script runs.
  const viewport = document.querySelector('.list-card-cms');
  const cards = viewport
    ? Array.from(viewport.children).filter(function (el) {
        return el.classList.contains('list-card-case');
      })
    : [];

  // .swipe-indicator-wrap now lives OUTSIDE the CMS list (a sibling of
  // .wrapper-list-card under .case-card), so it is never moved into the
  // draggable track and stays fixed while cards are swiped.
  const activeIndicator = document.querySelector('.case-card > .swipe-indicator-wrap .swipe-indicator');

  if (!viewport || cards.length === 0) {
    console.warn('[CardSwipe] .list-card-cms or .list-card-case not found.');
    return;
  }

  if (!activeIndicator) {
    console.warn('[CardSwipe] .swipe-indicator not found outside CMS 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; // 'x' | 'y' | 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() {
    // Measure against the viewport, not the track, since the track's
    // own width is derived from the cards and would create a circular reference.
    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') {
      // Vertical intent: let the page scroll natively, abort the drag.
      isDragging = false;
      return;
    }

    if (directionLock === 'x') {
      // Horizontal intent: take over the gesture, block page scroll.
      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) {
      // Still in range, but dimensions may have changed (e.g. rotation).
      measure();
      goToIndex(currentIndex, false);
    }
  }

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

  window.addEventListener('resize', handleResize);

});
</script>
Lenis Smooth Scroll
Usage notes:
  • No specific classes needed — loads globally.
  • Desktop only — gated at min-width 992px on window load.
  • Mobile only — active between 360–440px viewport width; auto disables/enables on resize.
  • Loads Lenis from CDN dynamically; exposes window.lenis for other scripts to hook into (e.g. ScrollTrigger.scrollerProxy integration).
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ── 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>
Service Card Auto-Rotator
Usage notes:
  • Built on Webflow's native Tabs component: tab triggers need class service-tab-link, tab panes service-tab-pane, wrapper tab-service-content.
  • Each tab link should contain: .service-card, .title-services-card, .services-description, .service-arrow, .line-service-card (accent line), .border-line-service (progress bar).
  • Auto-rotates every 5s (AUTO_DURATION); hovering any tab claims it as active and restarts the timer from there.
  • No manual init needed — runs and starts rotation automatically on page load.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

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

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

  // ── Config ──────────────────────────────────────────────
  const AUTO_DURATION = 5; // seconds per card
  const ACTIVE_CLASS = "is-active-service";
  const ACCENT_COLOR = "#820308";
  const DEFAULT_TITLE_COLOR = "var(--_colors---text-color--text-primary)";

  // ── DOM setup ───────────────────────────────────────────
  const tabLinks = Array.from(document.querySelectorAll(".service-tab-link"));
  const panes = Array.from(document.querySelectorAll(".service-tab-pane"));
  const contentWrap = document.querySelector(".tab-service-content");

  if (!tabLinks.length || !panes.length) {
    console.warn("[ServiceCardRotator] .service-tab-link or .service-tab-pane not found.");
    return;
  }

  // Panes are absolutely positioned so all three can share the same box
  // (required for autoAlpha crossfade). This needs a positioned ancestor —
  // set it here defensively in case .tab-service-content isn't already
  // position:relative in the Webflow stylesheet.
  if (contentWrap) {
    const currentPosition = getComputedStyle(contentWrap).position;
    if (currentPosition === "static") {
      gsap.set(contentWrap, { position: "relative" });
    }
  } else {
    console.warn("[ServiceCardRotator] .tab-service-content not found — pane absolute positioning may misplace elements.");
  }

  // Build a lookup array of card data, one entry per tab link / pane pair
  const cards = tabLinks.map(function (link, i) {
    return {
      link: link,
      card: link.querySelector(".service-card"),
      title: link.querySelector(".title-services-card"),
      description: link.querySelector(".services-description"),
      arrow: link.querySelector(".service-arrow"),
      accentLine: link.querySelector(".line-service-card"),
      borderLine: link.querySelector(".border-line-service"),
      pane: panes[i] || null,
      index: i
    };
  });

  // ── Initial state ───────────────────────────────────────
  cards.forEach(function (c) {
    if (c.accentLine) gsap.set(c.accentLine, { scaleY: 0, transformOrigin: "top center" });
    if (c.arrow) gsap.set(c.arrow, { autoAlpha: 0 });
    if (c.description) gsap.set(c.description, { display: "block", autoAlpha: 0, height: 0, overflow: "hidden" });
    if (c.borderLine) gsap.set(c.borderLine, { scaleX: 0, transformOrigin: "left center" });
    if (c.title) gsap.set(c.title, { color: "" });
    if (c.pane) gsap.set(c.pane, { display: "flex", position: "absolute", top: 0, left: 0, width: "100%", height: "100%", autoAlpha: 0 });
  });

  // ── State ───────────────────────────────────────────────
  let activeIndex = 0;      // index the master auto-rotate timer is currently on
  let displayIndex = 0;     // index currently shown visually
  let borderTween = null;
  let masterTimeout = null;

  // ── Apply visual "active" state to a given card index (does not touch the master timer) ──
  function showCard(index) {
    if (index === displayIndex && cards[index].card.classList.contains(ACTIVE_CLASS)) return;
    displayIndex = index;

    cards.forEach(function (c, i) {
      const isActive = i === index;
      c.card.classList.toggle(ACTIVE_CLASS, isActive);

      if (c.title) {
        gsap.to(c.title, {
          color: isActive ? ACCENT_COLOR : DEFAULT_TITLE_COLOR,
          duration: 0.3,
          overwrite: "auto"
        });
      }

      if (c.arrow) {
        gsap.to(c.arrow, {
          autoAlpha: isActive ? 1 : 0,
          duration: 0.3,
          overwrite: "auto"
        });
      }

      if (c.accentLine) {
        gsap.to(c.accentLine, {
          scaleY: isActive ? 1 : 0,
          duration: 0.4,
          ease: "power2.out",
          overwrite: "auto"
        });
      }

      if (c.description) {
        if (isActive) {
          // Force visible + auto height to measure the natural height,
          // then animate from 0 up to that measured value. Doing this
          // avoids GSAP's inability to tween height starting from
          // display:none (Webflow's default state for this class).
          gsap.set(c.description, { display: "block", height: "auto" });
          const naturalHeight = c.description.offsetHeight;
          gsap.fromTo(
            c.description,
            { height: 0, autoAlpha: 0 },
            {
              height: naturalHeight,
              autoAlpha: 1,
              duration: 0.4,
              ease: "power2.out",
              overwrite: "auto",
              onComplete: function () {
                gsap.set(c.description, { height: "auto" });
              }
            }
          );
        } else {
          gsap.to(c.description, {
            height: 0,
            autoAlpha: 0,
            duration: 0.3,
            ease: "power2.inOut",
            overwrite: "auto"
          });
        }
      }

      // Crossfade the matching image pane
      if (c.pane) {
        gsap.to(c.pane, {
          autoAlpha: isActive ? 1 : 0,
          duration: isActive ? 0.5 : 0.35,
          ease: "power1.inOut",
          overwrite: "auto"
        });
      }
    });
  }

  // ── Border-line progress animation (only reflects the real master timer, not hover) ──
  function playBorderProgress(index) {
    if (borderTween) borderTween.kill();

    cards.forEach(function (c) {
      if (c.borderLine) gsap.set(c.borderLine, { scaleX: 0 });
    });

    const target = cards[index].borderLine;
    if (!target) return;

    borderTween = gsap.to(target, {
      scaleX: 1,
      duration: AUTO_DURATION,
      ease: "none"
    });
  }

  // ── Master auto-rotate loop ──────────────────────────────
  // Advances activeIndex and restarts its own delayed call each cycle.
  // goToIndex() (called on hover) also restarts this cycle from scratch,
  // so hovering a card makes it the new anchor for auto-rotation —
  // it does not get overridden when the timer next fires.
  function goToNext() {
    goToIndex((activeIndex + 1) % cards.length);
  }

  function goToIndex(index) {
    if (masterTimeout) masterTimeout.kill();

    activeIndex = index;
    showCard(activeIndex);
    playBorderProgress(activeIndex);

    masterTimeout = gsap.delayedCall(AUTO_DURATION, goToNext);
  }

  // ── Hover handlers ────────────────────────────────────────
  // Hovering a card claims it as the active card permanently (until the
  // next hover or the next natural rotation) — moving the cursor away
  // does NOT revert to the previous card.
  cards.forEach(function (c) {
    c.link.addEventListener("mouseenter", function () {
      goToIndex(c.index);
    });
  });

  // ── Init ────────────────────────────────────────────────
  goToIndex(activeIndex);

});
</script>