Instruction

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 ScrollTrigger enabled. Inertia optional (script has fallback).
1. COUNTER ANIMATION
Animates numbers counting up when scrolled into view. Needs .counter-item wrappers each containing a .counter-number element with the target number as its text. Triggers via ScrollTrigger unless already in viewport on load.
<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-item");

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

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

  // Parses numbers where "." can mean EITHER a thousands separator (1.500 -> 1500)
  // OR a decimal point (96.4 -> 96.4), depending on convention used in this CMS.
  //
  // Disambiguation rule: if the LAST "." is followed by exactly 3 digits with
  // nothing after, treat it as a thousands separator and strip it.
  // Otherwise, treat it as a decimal point and preserve it.
  //
  // KNOWN LIMITATION: a genuine 3-decimal-place value (e.g. "4.250" meaning
  // 4.25) will be misread as 4250. If you have counters that need 3 decimal
  // places, flag it — this needs a manual override (e.g. a data-attribute),
  // not a heuristic.
  function parseFlexibleNumber(raw) {
    let str = raw.trim().replace(/[^\d.]/g, ""); // strip everything except digits and dots

    if (!str) return NaN;

    const lastDot = str.lastIndexOf(".");

    if (lastDot === -1) {
      return parseFloat(str);
    }

    const digitsAfterLastDot = str.length - lastDot - 1;

    if (digitsAfterLastDot === 3) {
      // Thousands separator: remove ALL dots
      str = str.replace(/\./g, "");
      return parseFloat(str);
    }

    // Decimal point: keep the last dot, strip any earlier dots
    // (handles rare cases like "1.234.5" -> "1234.5")
    const beforeLastDot = str.slice(0, lastDot).replace(/\./g, "");
    const afterLastDot = str.slice(lastDot + 1);
    return parseFloat(beforeLastDot + "." + afterLastDot);
  }

  function getDecimalPlaces(targetNumber) {
    if (!isFinite(targetNumber)) return 0;
    const str = targetNumber.toString();
    const dotIndex = str.indexOf(".");
    return dotIndex === -1 ? 0 : str.length - dotIndex - 1;
  }

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

  // 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 originalText = numberEl.textContent;
    const targetNumber = parseFlexibleNumber(originalText);

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

    const decimalPlaces = getDecimalPlaces(targetNumber);

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

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

});
</script>
2. LENIS SMOOTH SCROLL (DESKTOP)
Loads Lenis from a CDN and enables smooth scrolling, desktop only (min-width: 992px). No markup requirements. Exposes window.lenis for other scripts to control scroll (e.g. window.lenis.scrollTo(...)).
<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>
3. Testimonial Marquee
Turns .main-testimonial-wrap into an infinite auto-scrolling row of testimonials. Add the reverse class to make a row scroll right instead of left. Pauses on hover.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // Guard against double-init (Webflow can re-run custom code on page transitions / IX2 re-init)
  if (window._testimonialMarqueeInit) return;
  window._testimonialMarqueeInit = true;

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

  const wraps = document.querySelectorAll('.main-testimonial-wrap');
  if (!wraps.length) {
    console.warn("[TestimonialMarquee] .main-testimonial-wrap not found.");
    return;
  }

  const SPEED = 50; // px per second, lower = slower
  const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  wraps.forEach(function (wrap) {

    // .main-testimonial-wrap.reverse -> moves to the right
    const isReverse = wrap.classList.contains('reverse');

    let track;
    let tween;

    function build() {
      const items = Array.from(wrap.children);
      if (!items.length) return;

      track = document.createElement('div');
      track.className = 'testimonial-marquee-track';
      track.style.display = 'flex';
      track.style.width = 'max-content';
      track.style.willChange = 'transform';

      const gapValue = getComputedStyle(wrap).columnGap;
      if (gapValue) track.style.columnGap = gapValue;

      items.forEach(function (item) { track.appendChild(item); });

      if (!prefersReducedMotion) {
        items.forEach(function (item) {
          const clone = item.cloneNode(true);
          clone.setAttribute('aria-hidden', 'true');
          clone.removeAttribute('id');
          clone.querySelectorAll('[id]').forEach(function (el) { el.removeAttribute('id'); });
          clone.querySelectorAll('a, button, input, textarea, select').forEach(function (el) {
            el.setAttribute('tabindex', '-1');
          });
          track.appendChild(clone);
        });
      }

      track.dataset.originalCount = items.length;
      wrap.appendChild(track);
    }

    function measureDistance() {
      const n = parseInt(track.dataset.originalCount, 10);
      const children = track.children;
      const gap = parseFloat(getComputedStyle(track).columnGap) || 0;

      let distance = 0;
      for (let i = 0; i < n; i++) {
        distance += children[i].getBoundingClientRect().width;
      }
      distance += gap * n;

      return distance;
    }

    function startAnimation() {
      if (prefersReducedMotion) return;

      const distance = measureDistance();
      const duration = Math.max(distance / SPEED, 1);

      if (tween) tween.kill();

      if (isReverse) {
        // start from the left (-distance), move toward 0 => gives the impression of moving RIGHT
        gsap.set(track, { x: -distance });
        tween = gsap.to(track, {
          x: 0,
          duration: duration,
          ease: "none",
          repeat: -1
        });
      } else {
        // default: start from 0, move to -distance => moves LEFT
        gsap.set(track, { x: 0 });
        tween = gsap.to(track, {
          x: -distance,
          duration: duration,
          ease: "none",
          repeat: -1
        });
      }
    }

    build();
    startAnimation();

    if (!prefersReducedMotion) {
      wrap.addEventListener('mouseenter', function () {
        if (tween) tween.pause();
      });
      wrap.addEventListener('mouseleave', function () {
        if (tween) tween.play();
      });
    }

    let resizeTimer;
    let lastWidth = window.innerWidth;
    window.addEventListener('resize', function () {
      // On mobile, the address bar showing/hiding on scroll triggers a
      // 'resize' event even though the viewport WIDTH hasn't changed (only
      // the height changes). Without this check, startAnimation() would
      // trigger on every scroll and reset the tween (snapping back to its
      // starting position). So only restart when the width has actually
      // changed (device rotation / a real window resize).
      const currentWidth = window.innerWidth;
      if (currentWidth === lastWidth) return;
      lastWidth = currentWidth;

      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(startAnimation, 150);
    });

  });

});
</script>
4. Partner Logo Marquee
Same infinite-scroll idea as above, but for .partner-logo-wrap (client/partner logos). Waits for all logo images to finish loading before measuring and starting the loop, so the animation never desyncs due to late-loading images.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // PARTNER LOGO MARQUEE
  // ==================================================

  // ── Guard against double-init ──────────────────────────
  if (window._partnerMarqueeInit) 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";

  function waitForImages() {
    const imgs = Array.from(viewport.querySelectorAll("img"));
    if (imgs.length === 0) return Promise.resolve();

    return Promise.all(
      imgs.map((img) => {
        // Flipping loading to "eager" before it has started fetching
        // tells the browser to fetch it immediately instead of waiting
        // for the image to scroll near the viewport.
        if (img.loading === "lazy") img.loading = "eager";

        if (img.complete) return Promise.resolve();

        return new Promise((resolve) => {
          img.addEventListener("load", resolve, { once: true });
          img.addEventListener("error", resolve, { once: true });
        });
      })
    );
  }

  // ── Build (or rebuild) the seamless loop ───────────────
  function start() {
    if (window._partnerMarqueeInit) return;

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

    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. Safe to
      // do here because waitForImages() guarantees every logo has finished
      // loading by the time start() runs.
      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;

    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);
    });
  }

  // ── Only build the marquee once every logo image has actually loaded ──
  // (resolves near-instantly on a warm cache/refresh, waits properly on a
  // cold cache regardless of browser-specific lazy-load timing).
  waitForImages().then(start);

});
</script>
5. Service Card Hover/Expand
Powers .service-card elements that expand on hover (desktop) to reveal badges/image content, with a custom color+size tween. On tablet/mobile it switches to "two-tap" behavior: first tap opens the card, second tap follows the link.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

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

  var DURATION = 650; // ms — raised from 450 for a smoother feel

  function easeInOutCubic(t) {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }

  var cards = Array.prototype.slice.call(document.querySelectorAll('.service-card'));
  if (!cards.length) return;

  var container = cards[0].parentElement;

  // Tablet/mobile range where hover doesn't exist and taps need the
  // "open, then go to the link" two-step behavior.
  function isMobileBreakpoint() {
    return window.matchMedia('(min-width: 360px) and (max-width: 912px)').matches;
  }

  // ---- resolve a CSS var into a computed rgb() color, without writing any CSS ----
  function resolveColorVar(varExpression) {
    var probe = document.createElement('span');
    probe.style.position = 'absolute';
    probe.style.visibility = 'hidden';
    probe.style.pointerEvents = 'none';
    probe.style.color = varExpression;
    document.body.appendChild(probe);
    var resolved = getComputedStyle(probe).color;
    document.body.removeChild(probe);
    return resolved;
  }

  var ACCENT_COLOR = resolveColorVar('var(--_colors---core-neutral-color--accent)');

  function parseRGBA(str) {
    var m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
    if (!m) return { r: 0, g: 0, b: 0, a: 1 };
    return {
      r: parseInt(m[1], 10),
      g: parseInt(m[2], 10),
      b: parseInt(m[3], 10),
      a: m[4] !== undefined ? parseFloat(m[4]) : 1
    };
  }

  function getParts(card) {
    return {
      badges: card.querySelector('.badges-service-block'),
      img: card.querySelector('.img-service-inner')
    };
  }

  // ---- pure JS tween: supports size/opacity (px) as well as color (rgba) ----
  function animate(el, props, duration) {
    if (!el) return;
    if (el._tweenRAF) cancelAnimationFrame(el._tweenRAF);

    var startNum = {}, unit = {}, deltaNum = {};
    var startColor = {}, endColor = {}, colorKeys = [];

    Object.keys(props).forEach(function (key) {
      if (/Color$/i.test(key)) {
        colorKeys.push(key);
        var currentColor = el.style[key] || getComputedStyle(el)[key];
        startColor[key] = parseRGBA(currentColor);
        endColor[key] = parseRGBA(props[key]);
      } else {
        var isOpacity = key === 'opacity';
        var current = isOpacity
          ? parseFloat(el.style.opacity || '1')
          : (parseFloat(el.style[key]) || 0);
        unit[key] = isOpacity ? '' : 'px';
        startNum[key] = current;
        deltaNum[key] = props[key] - current;
      }
    });

    var startTime = null;

    function step(ts) {
      if (!startTime) startTime = ts;
      var progress = Math.min((ts - startTime) / duration, 1);
      var eased = easeInOutCubic(progress);

      Object.keys(startNum).forEach(function (key) {
        el.style[key] = (startNum[key] + deltaNum[key] * eased) + unit[key];
      });

      colorKeys.forEach(function (key) {
        var s = startColor[key], e = endColor[key];
        var r = Math.round(s.r + (e.r - s.r) * eased);
        var g = Math.round(s.g + (e.g - s.g) * eased);
        var b = Math.round(s.b + (e.b - s.b) * eased);
        var a = s.a + (e.a - s.a) * eased;
        el.style[key] = 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
      });

      if (progress < 1) {
        el._tweenRAF = requestAnimationFrame(step);
      } else {
        el._tweenRAF = null;
      }
    }

    el._tweenRAF = requestAnimationFrame(step);
  }

  function measureNaturalHeight(el) {
    var prevMax = el.style.maxHeight, prevOverflow = el.style.overflow;
    el.style.maxHeight = 'none';
    el.style.overflow = 'visible';
    var height = el.scrollHeight;
    el.style.maxHeight = prevMax;
    el.style.overflow = prevOverflow;
    return height;
  }

  function measureNaturalSize(el) {
    var prevMaxW = el.style.maxWidth, prevMaxH = el.style.maxHeight;
    el.style.maxWidth = 'none';
    el.style.maxHeight = 'none';
    var rect = el.getBoundingClientRect();
    el.style.maxWidth = prevMaxW;
    el.style.maxHeight = prevMaxH;
    return { width: rect.width, height: rect.height };
  }

  function prepare(card) {
    // store the original border color (from Designer) before we override anything
    card._defaultBorderColor = getComputedStyle(card).borderBottomColor;

    var parts = getParts(card);
    if (parts.badges) {
      parts.badges.style.overflow = 'hidden';
      parts.badges.style.maxHeight = '0px';
      parts.badges.style.opacity = '0';
      parts.badges.style.pointerEvents = 'none';
    }
    if (parts.img) {
      parts.img.style.overflow = 'hidden';
      parts.img.style.maxWidth = '0px';
      parts.img.style.maxHeight = '0px';
      parts.img.style.opacity = '0';
      parts.img.style.pointerEvents = 'none';
    }
  }

  function openCard(card) {
    if (card.classList.contains('is-open')) return;
    card.classList.add('is-open');
    var parts = getParts(card);

    animate(card, { borderBottomColor: ACCENT_COLOR }, DURATION);

    if (parts.badges) {
      parts.badges.style.pointerEvents = 'auto';
      animate(parts.badges, { maxHeight: measureNaturalHeight(parts.badges), opacity: 1 }, DURATION);
    }
    if (parts.img) {
      parts.img.style.pointerEvents = 'auto';
      var size = measureNaturalSize(parts.img);
      animate(parts.img, { maxWidth: size.width, maxHeight: size.height, opacity: 1 }, DURATION);
    }
  }

  function closeCard(card) {
    if (!card.classList.contains('is-open')) return;
    card.classList.remove('is-open');
    var parts = getParts(card);

    animate(card, { borderBottomColor: card._defaultBorderColor }, DURATION);

    if (parts.badges) {
      parts.badges.style.pointerEvents = 'none';
      animate(parts.badges, { maxHeight: 0, opacity: 0 }, DURATION);
    }
    if (parts.img) {
      parts.img.style.pointerEvents = 'none';
      animate(parts.img, { maxWidth: 0, maxHeight: 0, opacity: 0 }, DURATION);
    }
  }

  function setActiveCard(activeCard) {
    cards.forEach(function (card) {
      if (card === activeCard) openCard(card);
      else closeCard(card);
    });
  }

  cards.forEach(prepare);

  // Desktop opens the first card by default to mirror the initial hover
  // state. On tablet/mobile no card starts open — the first tap is what
  // reveals a card's content (see the two-tap logic below), so every card
  // needs the same first tap before it can be opened.
  if (!isMobileBreakpoint()) {
    setActiveCard(cards[0]);
  }

  cards.forEach(function (card) {
    card.addEventListener('mouseenter', function () {
      // Hover only drives open/close on non-touch breakpoints.
      if (isMobileBreakpoint()) return;
      setActiveCard(card);
    });

    // Two-tap behavior for tablet/mobile (360px–912px):
    // 1st tap on a closed card  -> opens it (same reveal as desktop hover)
    //                               and blocks the link navigation.
    // 2nd tap on an open card   -> lets the default link click go through.
    card.addEventListener('click', function (e) {
      if (!isMobileBreakpoint()) return;
      if (!card.classList.contains('is-open')) {
        e.preventDefault();
        setActiveCard(card);
      }
    });
  });

  if (container) {
    container.addEventListener('mouseleave', function () {
      if (isMobileBreakpoint()) return;
      setActiveCard(cards[0]);
    });
  }

});
</script>
6. Card Swipe (mobile touch slider)
Adds swipe/drag gestures on mobile (360–479px) for .card-services-wrap or .history-content-list card rows, with a progress indicator bar (.swipe-indicator-line). Detects horizontal vs. vertical touch intent so vertical page scroll still works.
<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;   // matches the CSS max-width:479px breakpoint used on
                                  // .card-services-wrap/.history-content-list, their cards,
                                  // and .swipe-indicator-wrap
  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 ───────────────────────────────────────────
  // Two sections on the site use this same swipe behavior with different
  // class names: the original "services" cards and the newer "history" cards.
  // Try each viewport/card pair in turn — whichever one exists on the page wins.
  // Each pair's viewport has overflow:clip at mobile (by design), so it acts as
  // the fixed "viewport". The individual repeated cards are its direct children
  // before this script runs.
  const VIEWPORT_CARD_PAIRS = [
    { viewportClass: 'card-services-wrap', cardClass: 'card-services' },
    { viewportClass: 'history-content-list', cardClass: 'card-history' }
  ];

  let viewport = null;
  let cards = [];

  for (let i = 0; i < VIEWPORT_CARD_PAIRS.length; i++) {
    const pair = VIEWPORT_CARD_PAIRS[i];
    const candidateViewport = document.querySelector('.' + pair.viewportClass);
    if (!candidateViewport) continue;

    const candidateCards = Array.from(candidateViewport.children).filter(function (el) {
      return el.classList.contains(pair.cardClass);
    });
    if (candidateCards.length === 0) continue;

    viewport = candidateViewport;
    cards = candidateCards;
    break;
  }

  if (!viewport || cards.length === 0) {
    console.warn('[CardSwipe] No matching viewport/card pair found (.card-services-wrap/.card-services or .history-content-list/.card-history).');
    return;
  }

  // .swipe-indicator-wrap lives OUTSIDE the viewport (a sibling of it,
  // under whatever their shared parent is), so it is never moved into the
  // draggable track and stays fixed while cards are swiped. Looked up via
  // viewport.parentElement instead of a hardcoded parent class name, so this
  // keeps working even if the section wrapper's class changes later. The
  // animated fill element is .swipe-indicator-line, nested inside
  // .swipe-indicator-line-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 .process-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; // '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>
7. Card Slider (mobile, arrow-based)
A separate mobile-only (≤479px) slider for .main-content-stand / .we-stand-block cards, navigated via prev/next arrow buttons instead of swiping, with slide transitions handled by GSAP.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // CARD SLIDER (MOBILE)
  // ==================================================

  // Guard: prevent double-init
  if (window._cardSliderInit) return;
  window._cardSliderInit = 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("[CardSlider] GSAP not found. Make sure GSAP is loaded before this script.");
    return;
  }

  // Reusable slider factory: same arrow classes, different wrap/card targets
  function initCardSlider(config) {
    var label = config.label;
    var wrapSelector = config.wrapSelector;
    var cardSelector = config.cardSelector;

    // DOM check
    var wrap = document.querySelector(wrapSelector);
    if (!wrap) {
      console.warn("[" + label + "] " + wrapSelector + " not found.");
      return;
    }

    var cards = Array.from(wrap.querySelectorAll(cardSelector));
    if (cards.length === 0) {
      console.warn("[" + label + "] No " + cardSelector + " found inside " + wrapSelector + ".");
      return;
    }

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

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

    // The desktop layout has .line-vertical / .line-horizontal divider elements
    // sitting BETWEEN the cards (siblings of cardSelector, not matched by it).
    // Once the cards below are switched to position:absolute, these dividers
    // are the only elements left in normal flow, so they collapse up to the
    // top of wrap and render as a stray line. Hide them for the slider.
    var dividers = Array.from(wrap.children).filter(function (el) {
      return !el.matches(cardSelector);
    });
    dividers.forEach(function (el) {
      gsap.set(el, { display: "none" });
    });

    // 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;

      // Measure and lock the wrap's height BEFORE any card goes absolute.
      // Doing this after would let wrap collapse to 0 for a frame (no more
      // in-flow children) and then snap back — that collapse/snap is what
      // exposes the hairline border sitting behind/above the wrap.
      var activeCardHeight = cards[0].getBoundingClientRect().height;
      gsap.set(wrap, { height: activeCardHeight });

      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
        });
      });
    }

    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.
          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].getBoundingClientRect().height });
    });
  }
  
  // Instance 1: existing team card slider (unchanged target)
  initCardSlider({
    label: "TeamCardSlider",
    wrapSelector: ".main-content-stand",
    cardSelector: ".we-stand-block"
  });
  
});
</script>