Instruction
Webflow Template User Guide
<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><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;
}
function runCounter(numberEl, targetNumber, index) {
const obj = { val: 0 };
gsap.to(obj, {
val: targetNumber,
duration: 4,
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><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><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><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
// .process-list-wrap / .single-process-wrap / .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 ───────────────────────────────────────────
// .process-list-wrap has overflow:clip at mobile (by design), so it acts as
// the fixed "viewport". The individual repeated cards (.single-process-wrap)
// are its direct children before this script runs.
const viewport = document.querySelector('.process-list-wrap');
const cards = viewport
? Array.from(viewport.children).filter(function (el) {
return el.classList.contains('single-process-wrap');
})
: [];
if (!viewport || cards.length === 0) {
console.warn('[CardSwipe] .process-list-wrap or .single-process-wrap cards not found.');
return;
}
// .swipe-indicator-wrap lives OUTSIDE .process-list-wrap (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><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(".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("[" + label + "] 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.
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 });
});
}
// Instance 1: existing team card slider (unchanged target)
initCardSlider({
label: "TeamCardSlider",
wrapSelector: ".team-member-list",
cardSelector: ".team-card-wrap"
});
// Instance 2: testimonial card slider (new target, same arrow classes)
initCardSlider({
label: "TestimonialCardSlider",
wrapSelector: ".testimonial-card",
cardSelector: ".card-testimonial"
});
});
</script>