Instruction
Webflow Template User Guide
Webflow Template User Guide
<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><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 () {
// 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><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 () {
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><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><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>