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 Draggable enabled. Inertia optional (script has fallback).
1. Main Cursor
What it does: Custom cursor that smoothly follows the mouse. Exposes window._cursorPause() and window._cursorResume() so other scripts can hide it on demand.
Required markup:
.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
  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. Testimonial Custom Cursor
What it does: A second custom cursor (e.g. "drag" icon) that appears only when hovering over the testimonial area. Automatically pauses the main cursor while active.
Required markup:
.testimonials-block — the area that triggers this cursor
.cursor-testimonials — the visual element shown when hovering
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // ==================================================
  // TESTIMONIAL CUSTOM CURSOR + DRAG FIX
  // ==================================================

  var trigger = document.querySelector(".testimonials-block");
  var cursor  = document.querySelector(".cursor-testimonials");

  if (!trigger || !cursor) return;

  // prevent double init
  if (cursor.dataset.init === "true") return;
  cursor.dataset.init = "true";

  // IMPORTANT
  // cursor stay outside testimonial
  // jangan appendChild lagi

  cursor.style.pointerEvents = "none";
  cursor.style.zIndex = "9999";
  cursor.style.position = "fixed";
  cursor.style.top = "0";
  cursor.style.left = "0";

  // disable browser drag
  trigger.querySelectorAll("*").forEach(function(el) {

    el.setAttribute("draggable", "false");

    el.addEventListener("dragstart", function(e) {
      e.preventDefault();
    });

  });

  // state
  var mouseX = window.innerWidth / 2;
  var mouseY = window.innerHeight / 2;

  var x = mouseX;
  var y = mouseY;

  var active = false;

  // initial
  gsap.set(cursor, {
    xPercent: -50,
    yPercent: -50,
    opacity: 0,
    scale: 0.8,
    x: x,
    y: y
  });

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

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

  });

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

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

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

    // detect inside
    var rect = trigger.getBoundingClientRect();

    var inside =
      mouseX >= rect.left &&
      mouseX <= rect.right &&
      mouseY >= rect.top &&
      mouseY <= rect.bottom;

    // ENTER
    if (inside && !active) {

      active = true;

      // hide default custom cursor
      if (window._cursorPause) {
        window._cursorPause();
      }

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

    }

    // LEAVE
    else if (!inside && active) {

      active = false;

      // show back default cursor
      if (window._cursorResume) {
        window._cursorResume();
      }

      gsap.to(cursor, {
        opacity: 0,
        scale: 0.8,
        duration: 0.3,
        ease: "power3.out"
      });

    }

  });

});
</script>
3. Testimonials Draggable
What it does: Horizontal drag carousel for testimonials, with momentum on release. Auto-recalculates bounds on resize.
Required markup:
.testimonials-block — visible container (defines drag range)
.testimonials-list-wrapper — wide content that gets dragged
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  var wrapper = document.querySelector(".testimonials-list-wrapper");
  var block   = document.querySelector(".testimonials-block");
  if (!wrapper || !block) return;

  var maxDrag = 0;

  function calcBounds() {
    var total = wrapper.scrollWidth;
    var visible = block.offsetWidth;
    maxDrag = Math.max(0, total - visible);
  }

  calcBounds();
  if (maxDrag === 0) return;

  gsap.set(wrapper, { x: 0 });

  // Manual velocity tracking for momentum on release.
  // Used as a fallback if InertiaPlugin is not loaded at runtime.
  var lastX = 0;
  var lastTime = 0;
  var velocity = 0;

  var instance = Draggable.create(wrapper, {
    type: "x",
    bounds: { minX: -maxDrag, maxX: 0 },
    inertia: true,
    edgeResistance: 0.75,
    dragResistance: 0.05,
    cursor: "grab",
    activeCursor: "grabbing",
    dragClickables: true,

    onPress: function () {
      lastX = this.x;
      lastTime = Date.now();
      velocity = 0;
    },

    onDrag: function () {
      var now = Date.now();
      var dt = now - lastTime;
      if (dt > 0) {
        velocity = (this.x - lastX) / dt;
      }
      lastX = this.x;
      lastTime = now;
    },

    onDragEnd: function () {
      // If InertiaPlugin already started a tween, let it run.
      if (gsap.isTweening(wrapper)) return;

      // Otherwise, simulate momentum manually.
      var projected = this.x + velocity * 300;
      var target = Math.max(-maxDrag, Math.min(0, projected));

      gsap.to(wrapper, {
        x: target,
        duration: 1.4,
        ease: "power3.out"
      });
    }
  })[0];

  var resizeTimer;
  window.addEventListener("resize", function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () {
      calcBounds();
      instance.applyBounds({ minX: -maxDrag, maxX: 0 });
    }, 200);
  });

});
</script>
4. Hero & CTA & Section Reveals
What it does: Three scroll/load animation blocks bundled together
Required markup:
Hero: .counter-wrap, .hero-testimonial-wrap, .hero-subtitle-wrap, .top-headline, .subheadline, .hero-cta-wrap.w-nav
CTA: .cta-section containing .tag-section, .title-content-wrap, .subheadline, .button-wrap 
Section reveals: .section-heading, .section-subtitle, .section-description
<script>
document.addEventListener("DOMContentLoaded", function () {

  // ===== helpers (avoid $ clash with jQuery that Webflow ships) =====
  const q  = (sel, root = document) => root.querySelector(sel);
  const qa = (sel, root = document) => Array.from(root.querySelectorAll(sel));

  // ============================================================
  // 1. HERO
  // ============================================================
  (function initHero() {
    const nav         = q(".w-nav");
    const counter     = q(".counter-wrap");
    const testimonial = q(".hero-testimonial-wrap");
    const subtitle    = q(".hero-subtitle-wrap");
    const headline    = q(".top-headline");
    const subheadline = q(".subheadline");
    const cta         = q(".hero-cta-wrap");

    // bail out entirely if hero isn't on this page
    if (!headline && !nav) return;

    // initial states
    if (nav) gsap.set(nav, { opacity: 0, y: -24, filter: "blur(8px)" });

    const floaters = [counter, testimonial].filter(Boolean);
    if (floaters.length) {
      gsap.set(floaters, { opacity: 0, y: 40, scale: 0.96, filter: "blur(14px)" });
    }

    if (subtitle)    gsap.set(subtitle,    { opacity: 0, y: 50,  filter: "blur(12px)" });
    if (headline)    gsap.set(headline,    { opacity: 0, y: 120, scale: 0.92, filter: "blur(24px)" });
    if (subheadline) gsap.set(subheadline, { opacity: 0, y: 50,  filter: "blur(14px)" });
    if (cta)         gsap.set(cta,         { opacity: 0, y: 50,  filter: "blur(14px)" });

    // timeline
    const tl = gsap.timeline({ defaults: { ease: "power3.out" } });

    if (nav) {
      tl.to(nav, {
        opacity: 1, y: 0, filter: "blur(0px)", duration: 1,
        onComplete: () => gsap.set(nav, { clearProps: "filter" })
      });
    }

    if (floaters.length) {
      tl.to(floaters, {
        opacity: 1, y: 0, scale: 1, filter: "blur(0px)",
        duration: 1.1, stagger: 0.15, ease: "power4.out",
        onComplete: () => gsap.set(floaters, { clearProps: "filter" })
      }, "-=0.5");
    }

    if (subtitle) {
      tl.to(subtitle, {
        opacity: 1, y: 0, filter: "blur(0px)", duration: 0.9,
        onComplete: () => gsap.set(subtitle, { clearProps: "filter" })
      }, "-=0.6");
    }

    if (headline) {
      tl.to(headline, {
        opacity: 1, y: 0, scale: 1, filter: "blur(0px)",
        duration: 1.5, ease: "power4.out",
        onComplete: () => gsap.set(headline, { clearProps: "filter" })
      }, "-=0.4");
    }

    if (subheadline) {
      tl.to(subheadline, {
        opacity: 1, y: 0, filter: "blur(0px)", duration: 1,
        onComplete: () => gsap.set(subheadline, { clearProps: "filter" })
      }, "-=1");
    }

    if (cta) {
      tl.to(cta, {
        opacity: 1, y: 0, filter: "blur(0px)", duration: 1,
        onComplete: () => gsap.set(cta, { clearProps: "filter" })
      }, "-=0.8");
    }

    // floating testimonial loop
    if (testimonial) {
      gsap.to(testimonial, {
        y: "-=10", duration: 2.8, repeat: -1, yoyo: true, ease: "sine.inOut"
      });
    }
  })();

  // ============================================================
  // 2. CTA SECTION (scroll-triggered)
  // ============================================================
  (function initCta() {
    const section = q(".cta-section");
    if (!section) return;

    const tag     = q(".tag-section", section);
    const title   = q(".title-content-wrap", section);
    const sub     = q(".subheadline", section);
    const buttons = qa(".button-wrap", section);

    const fadeGroup = [tag, sub, ...buttons].filter(Boolean);
    if (fadeGroup.length) {
      gsap.set(fadeGroup, { opacity: 0, y: 40, filter: "blur(10px)" });
    }
    if (title) {
      gsap.set(title, { opacity: 0, y: 80, scale: 0.96, filter: "blur(18px)" });
    }

    const tl = gsap.timeline({
      scrollTrigger: { trigger: section, start: "top 80%", once: true }
    });

    if (tag) {
      tl.to(tag, {
        opacity: 1, y: 0, filter: "blur(0px)", duration: 0.8, ease: "power3.out",
        onComplete: () => gsap.set(tag, { clearProps: "filter" })
      });
    }
    if (title) {
      tl.to(title, {
        opacity: 1, y: 0, scale: 1, filter: "blur(0px)",
        duration: 1.2, ease: "power4.out",
        onComplete: () => gsap.set(title, { clearProps: "filter" })
      }, "-=0.45");
    }
    if (sub) {
      tl.to(sub, {
        opacity: 1, y: 0, filter: "blur(0px)", duration: 0.8, ease: "power3.out",
        onComplete: () => gsap.set(sub, { clearProps: "filter" })
      }, "-=0.8");
    }
    if (buttons.length) {
      tl.to(buttons, {
        opacity: 1, y: 0, filter: "blur(0px)",
        duration: 0.7, stagger: 0.14, ease: "power3.out",
        onComplete: () => gsap.set(buttons, { clearProps: "filter" })
      }, "-=0.55");
    }
  })();

  // ============================================================
  // 3. SECTION REVEALS (scroll-triggered, per-element)
  // ============================================================
(function initSectionReveals() {
  const headings    = qa(".section-heading");
  const subtitles   = qa(".section-subtitle");
  const descs       = qa(".section-description");

  const allEls = [...headings, ...subtitles, ...descs];
  if (!allEls.length) return;

  allEls.forEach(el => {
    const isHeading = el.classList.contains("section-heading");

    gsap.set(el, {
      opacity: 0,
      y: isHeading ? 50 : 24,
      scale: isHeading ? 0.98 : 1,
      filter: isHeading ? "blur(16px)" : "blur(10px)",
      willChange: "transform, opacity, filter"
    });

    gsap.to(el, {
      opacity: 1, y: 0, scale: 1, filter: "blur(0px)",
      duration: isHeading ? 0.9 : 0.6,
      ease: isHeading ? "power4.out" : "power3.out",
      scrollTrigger: { trigger: el, start: "top 88%", once: true },
      onComplete: () => gsap.set(el, { clearProps: "willChange,filter" })
    });
  });
})();

});
</script>
5. Team Card Hover (Desktop)
What it does: Horizontal accordion of team cards. First card is expanded (40vw), others collapsed (14vw). Hovering a card expands it and collapses the previous one. Each card's .img-team-side slides via xPercent during the transition.
Required markup:
.wrapper-image-team — flex row container
.card-team — individual cards (each contains optional .img-team-side)
Behavior:
- Disabled on touch devices and viewports ≤ 479px.
- Recalculates widths on resize.
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  var isTouchDevice = window.matchMedia('(hover: none)').matches;
  if (isTouchDevice) return;
  if (window.matchMedia('(max-width: 479px)').matches) return;

  var wrapper = document.querySelector('.wrapper-image-team');
  if (!wrapper) return;
  var cards = wrapper.querySelectorAll('.card-team');
  if (cards.length === 0) return;

  // ===== Tweakable settings =====
  var DURATION = 0.75;
  var EASE = 'power2.inOut';
  var SIDE_OFFSET = -101;
  var EXPANDED_VW = 40;
  var COLLAPSED_VW = 14;
  // ==============================

  var activeCard = cards[0];

  function vwToPx(vw) {
    return (window.innerWidth * vw) / 100;
  }

  function getExpandedWidth() {
    return vwToPx(EXPANDED_VW);
  }

  function getCollapsedWidth() {
    return vwToPx(COLLAPSED_VW);
  }

  function getSideElement(card) {
    return card.querySelector('.img-team-side');
  }

  // ===== Initial state =====
  function setInitialState() {
    gsap.set(cards, { width: getCollapsedWidth() });

    cards.forEach(function (card) {
      var side = getSideElement(card);
      if (side) gsap.set(side, { xPercent: 0 });
    });

    gsap.set(activeCard, { width: getExpandedWidth() });
    var activeSide = getSideElement(activeCard);
    if (activeSide) gsap.set(activeSide, { xPercent: SIDE_OFFSET });
  }

  // ===== Animate expand/collapse =====
  function expandCard(targetCard) {
    if (targetCard === activeCard) return;

    var expandedWidth = getExpandedWidth();
    var collapsedWidth = getCollapsedWidth();

    var prevCard = activeCard;
    var prevSide = getSideElement(prevCard);
    var targetSide = getSideElement(targetCard);

    activeCard = targetCard;

    // Collapse previous card
    gsap.to(prevCard, {
      width: collapsedWidth,
      duration: DURATION,
      ease: EASE
    });
    if (prevSide) {
      gsap.to(prevSide, {
        xPercent: 0,
        duration: DURATION,
        ease: EASE
      });
    }

    // Expand target card
    gsap.to(targetCard, {
      width: expandedWidth,
      duration: DURATION,
      ease: EASE
    });
    if (targetSide) {
      gsap.to(targetSide, {
        xPercent: SIDE_OFFSET,
        duration: DURATION,
        ease: EASE
      });
    }
  }

  // ===== Initialize =====
  setInitialState();

  // ===== Event listeners =====
  cards.forEach(function (card) {
    card.addEventListener('mouseenter', function () {
      expandCard(card);
    });
  });

  // Resize handler — re-set state based on new viewport width.
  // Skip on mobile (<480px) where the effect is disabled anyway.
  var resizeTimer;
  window.addEventListener('resize', function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () {
      if (window.matchMedia('(max-width: 479px)').matches) {
        // On mobile, reset all to default so CSS controls the layout
        gsap.set(cards, { width: '' });
        cards.forEach(function (card) {
          var side = getSideElement(card);
          if (side) gsap.set(side, { xPercent: 0 });
        });
        return;
      }
      setInitialState();
    }, 150);
  });

});
</script>
6. Team Card Mobile Carousel
What it does: On mobile (≤ 479px), all team cards stack on top of each other. Left/right arrow buttons cycle through them with a fade + slide transition. Loops at both ends (last → first, first → last).
Required markup:
.wrapper-image-team — stacking container (cards position: absolute in CSS)
.card-team — individual cards
.team-arrow-circle — exactly 2, in DOM order: first = previous (left), second = next (right)
<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {

  // Only run on mobile viewport (<= 479px) where cards are stacked.
  if (!window.matchMedia('(max-width: 479px)').matches) return;

  var wrapper = document.querySelector('.wrapper-image-team');
  if (!wrapper) return;

  var cards = wrapper.querySelectorAll('.card-team');
  if (cards.length < 2) return;

  var arrows = document.querySelectorAll('.team-arrow-circle');
  if (arrows.length < 2) return;

  var prevBtn = arrows[0]; // left arrow = previous
  var nextBtn = arrows[1]; // right arrow = next

  // ===== Tweakable settings =====
  var DURATION = 0.6;
  var EASE = 'power3.out';
  var SLIDE = 60; // px the card slides in/out horizontally
  // ==============================

  var current = 0;
  var animating = false;

  // Initial state: first card visible and centered, the rest hidden.
  for (var i = 0; i < cards.length; i++) {
    gsap.set(cards[i], {
      opacity: i === 0 ? 1 : 0,
      x: 0,
      zIndex: i === 0 ? 2 : 1
    });
  }

  // dir = 1 means going forward (next), -1 means going back (prev)
  function goTo(nextIndex, dir) {
    if (animating) return;
    if (nextIndex === current) return;

    animating = true;

    var oldCard = cards[current];
    var newCard = cards[nextIndex];

    // Incoming card sits above the others.
    gsap.set(newCard, { zIndex: 2 });
    gsap.set(oldCard, { zIndex: 1 });

    // Old card fades out and slides away in the travel direction.
    gsap.to(oldCard, {
      opacity: 0,
      x: -SLIDE * dir,
      duration: DURATION,
      ease: EASE
    });

    // New card starts off-screen on the opposite side, then
    // fades in and slides to center.
    gsap.set(newCard, { x: SLIDE * dir });
    gsap.to(newCard, {
      opacity: 1,
      x: 0,
      duration: DURATION,
      ease: EASE,
      onComplete: function () {
        animating = false;
      }
    });

    current = nextIndex;
  }

  function next() {
    var i = (current + 1) % cards.length;
    goTo(i, 1);
  }

  function prev() {
    var i = (current - 1 + cards.length) % cards.length;
    goTo(i, -1);
  }

  nextBtn.addEventListener('click', next);
  prevBtn.addEventListener('click', prev);

});
</script>