Instructions
Webflow Template User Guide
window._cursorPause() and window._cursorResume() to hide/show the pointer ring.<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ==================================================
// MAIN CURSOR
// ==================================================
if (window._cursorCoreInit) return;
window._cursorCoreInit = true;
var cursor = document.querySelector(".cursor-core");
var pointer = document.querySelector(".cursor-pointer");
if (!cursor) return;
var mouseX = -100;
var mouseY = -100;
var x = -100;
var y = -100;
var paused = false;
// initial position
gsap.set(cursor, {
xPercent: -50,
yPercent: -50,
x: x,
y: y
});
// smooth follow
gsap.ticker.add(function () {
x += (mouseX - x) * 0.15;
y += (mouseY - y) * 0.15;
gsap.set(cursor, {
x: x,
y: y
});
});
// track mouse
window.addEventListener("mousemove", function (e) {
mouseX = e.clientX;
mouseY = e.clientY;
});
// =========================================
// HIDE POINTER ONLY
// =========================================
window._cursorPause = function () {
paused = true;
if (!pointer) return;
gsap.to(pointer, {
opacity: 0,
duration: 0.2,
overwrite: true
});
};
// =========================================
// SHOW POINTER AGAIN
// =========================================
window._cursorResume = function () {
paused = false;
if (!pointer) return;
gsap.to(pointer, {
opacity: 1,
duration: 0.2,
overwrite: true
});
};
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ── Guard against double-init ──────────────────────────
// Set to true only after setup succeeds further down, so a failed/partial
// run (e.g. GSAP not yet loaded) can retry on the next call instead of
// permanently blocking the script.
if (window._partnerMarqueeInit) return;
// ── GSAP availability check ────────────────────────────
if (typeof gsap === "undefined") {
console.error("[PartnerMarquee] GSAP not found.");
return;
}
// ── DOM check ──────────────────────────────────────────
// .partner-logo-wrap is the VIEWPORT: it already has overflow:hidden in
// its own CSS. It must stay static — it is the clipping window, not the
// element that moves. A separate inner track div is created below to
// hold the logos and be the thing GSAP translates.
const viewport = document.querySelector(".partner-logo-wrap");
if (!viewport) {
console.warn("[PartnerMarquee] .partner-logo-wrap not found.");
return;
}
const TRACK_CLASS = "partner-marquee-track";
let track = viewport.querySelector(":scope > ." + TRACK_CLASS);
if (!track) {
// First run: build the track from the original .logo-wrap children.
const originalChildren = Array.from(viewport.children);
if (originalChildren.length === 0) {
console.warn("[PartnerMarquee] .partner-logo-wrap has no logo children.");
return;
}
// Read the gap Webflow already applies to .partner-logo-wrap so the
// track reproduces the same spacing between logos.
const computedGap = getComputedStyle(viewport).columnGap;
// Lock each logo's natural rendered width BEFORE moving it into a new
// flex context, so flex-basis recalculation can't shrink it.
originalChildren.forEach((child) => {
const naturalWidth = child.getBoundingClientRect().width;
child.style.flex = "0 0 auto";
child.style.width = naturalWidth + "px";
});
// Build the track div that will actually be translated.
track = document.createElement("div");
track.className = TRACK_CLASS;
track.style.display = "flex";
track.style.flexWrap = "nowrap";
track.style.alignItems = "center";
track.style.columnGap = computedGap;
track.style.width = "max-content";
// Move the original logos into the track (appendChild moves existing
// nodes, it does not clone them).
originalChildren.forEach((child) => track.appendChild(child));
// Clone the set once more inside the track so the loop has no seam.
originalChildren.forEach((child) => {
const clone = child.cloneNode(true);
clone.setAttribute("aria-hidden", "true");
track.appendChild(clone);
});
viewport.appendChild(track);
}
let marqueeTween;
// ── Build (or rebuild) the seamless loop ───────────────
function buildMarquee() {
if (marqueeTween) {
marqueeTween.kill();
gsap.set(track, { x: 0 });
}
// Half of the track's total width = width of one full logo set.
const distance = track.scrollWidth / 2;
marqueeTween = gsap.to(track, {
x: -distance,
duration: distance / 50, // adjust divisor to tune speed (px per second)
ease: "none",
repeat: -1,
});
}
buildMarquee();
// Setup succeeded — now safe to mark as initialized.
window._partnerMarqueeInit = true;
// ── Hover pause / resume ───────────────────────────────
// Listeners on the viewport (the visible, static, hoverable box).
viewport.addEventListener("mouseenter", () => {
if (marqueeTween) marqueeTween.pause();
});
viewport.addEventListener("mouseleave", () => {
if (marqueeTween) marqueeTween.resume();
});
// ── Rebuild on resize (debounced) so distance stays accurate ──
let resizeTimeout;
window.addEventListener("resize", () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(buildMarquee, 250);
});
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ==================================================
// COUNTER ANIMATION
// ==================================================
if (window._counterInit) return;
window._counterInit = true;
if (typeof gsap === "undefined" || typeof ScrollTrigger === "undefined") {
console.error("[Counter] GSAP or ScrollTrigger not found.");
return;
}
gsap.registerPlugin(ScrollTrigger);
const counters = document.querySelectorAll(".counter-wrap");
if (!counters.length) {
console.warn("[Counter] No .counter-wrap elements found.");
return;
}
function isInViewport(el) {
const rect = el.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0;
}
function runCounter(numberEl, targetNumber, index) {
const obj = { val: 0 };
gsap.to(obj, {
val: targetNumber,
duration: 2,
delay: index * 0.15,
ease: "power2.out",
onUpdate: function () {
numberEl.textContent = Math.round(obj.val);
},
onComplete: function () {
numberEl.textContent = targetNumber;
}
});
}
// Read all target values BEFORE modifying anything in the DOM
const items = [];
counters.forEach(function (wrap, index) {
const numberEl = wrap.querySelector(".counter-number");
if (!numberEl) {
console.warn("[Counter] .counter-number not found at index:", index);
return;
}
const targetNumber = parseInt(numberEl.textContent.trim().replace(/\D/g, ""), 10);
if (isNaN(targetNumber) || targetNumber === 0) {
console.warn("[Counter] Invalid value at index:", index, "| value:", numberEl.textContent);
return;
}
// Store reference and target, then reset display to 0
items.push({ wrap, numberEl, targetNumber, index });
numberEl.textContent = "0";
});
// Set up animations only after all targets are stored
items.forEach(function ({ wrap, numberEl, targetNumber, index }) {
if (isInViewport(wrap)) {
setTimeout(function () {
runCounter(numberEl, targetNumber, index);
}, 100);
} else {
ScrollTrigger.create({
trigger: wrap,
start: "top 85%",
once: true,
onEnter: function () {
runCounter(numberEl, targetNumber, index);
}
});
}
});
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
if (window._caseHoverCursorInit) return;
window._caseHoverCursorInit = true;
// Desktop-only gate — skip entirely on tablet/mobile (360px–912px)
if (window.matchMedia("(max-width: 912px)").matches) return;
if (typeof gsap === "undefined") {
console.error("[CaseHoverCursor] GSAP not found.");
return;
}
var cards = document.querySelectorAll(".link-card-case");
if (!cards.length) {
console.warn("[CaseHoverCursor] .link-card-case not found.");
return;
}
cards.forEach(function (card) {
var badge = card.querySelector(".hover-case-studies");
if (!badge) return;
var quickX = gsap.quickTo(badge, "x", { duration: 0.4, ease: "power3" });
var quickY = gsap.quickTo(badge, "y", { duration: 0.4, ease: "power3" });
gsap.set(badge, { opacity: 0, scale: 0.6 });
card.addEventListener("mouseenter", function () {
if (typeof window._cursorPause === "function") {
window._cursorPause();
}
// Do NOT use overwrite: true here — it kills the quickTo x/y tweens
// on the same target because overwrite:true clears ALL tweens on
// the target regardless of property. Default "auto" only clears
// conflicting properties (opacity/scale), leaving x/y intact.
gsap.to(badge, {
opacity: 1,
scale: 1,
duration: 0.3,
ease: "power2.out"
});
});
card.addEventListener("mousemove", function (e) {
var rect = card.getBoundingClientRect();
var relX = e.clientX - rect.left;
var relY = e.clientY - rect.top;
quickX(relX);
quickY(relY);
});
card.addEventListener("mouseleave", function () {
if (typeof window._cursorResume === "function") {
window._cursorResume();
}
gsap.to(badge, {
opacity: 0,
scale: 0.6,
duration: 0.3,
ease: "power2.in"
});
});
});
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// Guard: prevent double-init
if (window._teamCardSliderInit) return;
window._teamCardSliderInit = true;
// Guard: only active on mobile (max 479px)
if (!window.matchMedia("(max-width: 479px)").matches) return;
// GSAP availability check
if (typeof gsap === "undefined") {
console.error("[TeamCardSlider] GSAP not found. Make sure GSAP is loaded before this script.");
return;
}
// DOM check
var wrap = document.querySelector(".team-member-list");
if (!wrap) {
console.warn("[TeamCardSlider] .team-member-list not found.");
return;
}
var cards = Array.from(wrap.querySelectorAll(".team-card"));
if (cards.length === 0) {
console.warn("[TeamCardSlider] No .team-card found inside .team-member-list.");
return;
}
var btnPrev = document.querySelector(".all-arrow-team .arrow-team-wrap:first-child");
var btnNext = document.querySelector(".all-arrow-team .arrow-team-wrap:last-child");
if (!btnPrev || !btnNext) {
console.warn("[TeamCardSlider] Arrow buttons not found.");
return;
}
// Setup: overflow hidden on wrap so off-frame cards are invisible
gsap.set(wrap, {
overflow: "hidden",
position: "relative"
});
var totalCards = cards.length;
var currentIndex = 0;
var isAnimating = false;
var containerWidth = wrap.offsetWidth;
// Set all cards to absolute positioning, stacked at the same origin.
// Only the active card is at x:0. All others are pushed off-frame (x: containerWidth).
function initPositions() {
containerWidth = wrap.offsetWidth;
cards.forEach(function (card, i) {
gsap.set(card, {
position: "absolute",
top: 0,
left: 0,
width: containerWidth,
flexShrink: 0,
// All inactive cards start off-frame to the right
x: i === 0 ? 0 : containerWidth
});
});
// Give wrap an explicit height so it doesn't collapse after cards go absolute
gsap.set(wrap, { height: cards[0].offsetHeight });
}
initPositions();
// Navigation function
function goTo(newIndex) {
if (isAnimating || newIndex === currentIndex) return;
isAnimating = true;
containerWidth = wrap.offsetWidth;
// Determine slide direction:
// +1 = sliding left (next), -1 = sliding right (prev)
var direction;
if (newIndex > currentIndex) {
direction = 1;
} else {
direction = -1;
}
// Detect wraparound cases
if (currentIndex === totalCards - 1 && newIndex === 0) direction = 1;
if (currentIndex === 0 && newIndex === totalCards - 1) direction = -1;
var cardOut = cards[currentIndex];
var cardIn = cards[newIndex];
// Place incoming card just off-frame in the correct direction
gsap.set(cardIn, { x: direction * containerWidth });
var tl = gsap.timeline({
onComplete: function () {
// Push the outgoing card fully off-frame and keep it there.
// This is the fix: card lama dikirim jauh ke luar frame setelah animasi selesai.
gsap.set(cardOut, { x: -direction * containerWidth });
isAnimating = false;
currentIndex = newIndex;
}
});
// Outgoing card slides out
tl.to(cardOut, {
x: -direction * containerWidth,
duration: 0.5,
ease: "power2.inOut"
}, 0);
// Incoming card slides in
tl.to(cardIn, {
x: 0,
duration: 0.5,
ease: "power2.inOut"
}, 0);
}
// Button listeners
btnNext.addEventListener("click", function () {
var nextIndex = (currentIndex + 1) % totalCards;
goTo(nextIndex);
});
btnPrev.addEventListener("click", function () {
var prevIndex = (currentIndex - 1 + totalCards) % totalCards;
goTo(prevIndex);
});
// Resize handler: recalculate on orientation change
window.addEventListener("resize", function () {
if (!window.matchMedia("(max-width: 479px)").matches) return;
containerWidth = wrap.offsetWidth;
// Re-pin active card at x:0, push all others off-frame
cards.forEach(function (card, i) {
gsap.set(card, {
width: containerWidth,
x: i === currentIndex ? 0 : containerWidth
});
});
gsap.set(wrap, { height: cards[currentIndex].offsetHeight });
});
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ── Guard against double-init ──────────────────────────
if (window._cardSwipeInit) return;
window._cardSwipeInit = true;
// ── GSAP availability check ────────────────────────────
if (typeof gsap === "undefined") {
console.error("[CardSwipe] GSAP not found.");
return;
}
// ── Config ──────────────────────────────────────────────
const MOBILE_MIN_WIDTH = 360;
const MOBILE_MAX_WIDTH = 440;
const SWIPE_DISTANCE_RATIO = 0.2; // % of card width needed to trigger a step
const SWIPE_VELOCITY_THRESHOLD = 0.5; // px/ms, for fast flicks below the distance ratio
const DIRECTION_LOCK_THRESHOLD = 8; // px, before we decide horizontal vs vertical intent
// ── DOM check ───────────────────────────────────────────
// .list-card-cms has overflow:hidden (by design), so it acts as the
// fixed "viewport". The individual CMS-repeated cards are its direct
// children before this script runs.
const viewport = document.querySelector('.list-card-cms');
const cards = viewport
? Array.from(viewport.children).filter(function (el) {
return el.classList.contains('list-card-case');
})
: [];
// .swipe-indicator-wrap now lives OUTSIDE the CMS list (a sibling of
// .wrapper-list-card under .case-card), so it is never moved into the
// draggable track and stays fixed while cards are swiped.
const activeIndicator = document.querySelector('.case-card > .swipe-indicator-wrap .swipe-indicator');
if (!viewport || cards.length === 0) {
console.warn('[CardSwipe] .list-card-cms or .list-card-case not found.');
return;
}
if (!activeIndicator) {
console.warn('[CardSwipe] .swipe-indicator not found outside CMS scope — swipe will still work, indicator will not update.');
}
const totalCards = cards.length;
// ── State ───────────────────────────────────────────────
let track = null;
let cardWidth = 0;
let gapPx = 0;
let currentIndex = 0;
let baseX = 0;
let isSwipeActive = false;
let isDragging = false;
let directionLock = null; // 'x' | 'y' | null
let startX = 0;
let startY = 0;
let startTime = 0;
// ── Helpers ─────────────────────────────────────────────
function checkMobileRange() {
const w = window.innerWidth;
return w >= MOBILE_MIN_WIDTH && w <= MOBILE_MAX_WIDTH;
}
function buildTrack() {
track = document.createElement('div');
track.style.display = 'flex';
track.style.flexFlow = 'row';
track.style.willChange = 'transform';
viewport.style.position = viewport.style.position || 'relative';
viewport.style.overflow = 'hidden';
cards.forEach(function (card) {
track.appendChild(card);
});
viewport.appendChild(track);
}
function teardownTrack() {
if (!track) return;
cards.forEach(function (card) {
card.style.width = '';
card.style.flex = '';
viewport.appendChild(card);
});
track.remove();
track = null;
}
function measure() {
// Measure against the viewport, not the track, since the track's
// own width is derived from the cards and would create a circular reference.
cardWidth = viewport.getBoundingClientRect().width;
gapPx = parseFloat(window.getComputedStyle(viewport).columnGap) || 0;
cards.forEach(function (card) {
card.style.width = cardWidth + 'px';
card.style.flex = '0 0 auto';
});
track.style.columnGap = gapPx + 'px';
}
function updateIndicator(index) {
if (!activeIndicator) return;
const percent = ((index + 1) / totalCards) * 100;
gsap.to(activeIndicator, { width: percent + '%', duration: 0.3, ease: 'power2.out' });
}
function goToIndex(index, animate) {
currentIndex = Math.max(0, Math.min(totalCards - 1, index));
const targetX = -currentIndex * (cardWidth + gapPx);
if (animate === false) {
gsap.set(track, { x: targetX });
} else {
gsap.to(track, { x: targetX, duration: 0.4, ease: 'power3.out' });
}
baseX = targetX;
updateIndicator(currentIndex);
}
// ── Touch handlers ──────────────────────────────────────
function onTouchStart(e) {
isDragging = true;
directionLock = null;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
startTime = Date.now();
gsap.killTweensOf(track);
}
function onTouchMove(e) {
if (!isDragging) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const deltaX = currentX - startX;
const deltaY = currentY - startY;
if (directionLock === null) {
if (Math.abs(deltaX) > DIRECTION_LOCK_THRESHOLD || Math.abs(deltaY) > DIRECTION_LOCK_THRESHOLD) {
directionLock = Math.abs(deltaX) > Math.abs(deltaY) ? 'x' : 'y';
}
}
if (directionLock === 'y') {
// Vertical intent: let the page scroll natively, abort the drag.
isDragging = false;
return;
}
if (directionLock === 'x') {
// Horizontal intent: take over the gesture, block page scroll.
e.preventDefault();
gsap.set(track, { x: baseX + deltaX });
}
}
function onTouchEnd(e) {
if (!isDragging || directionLock !== 'x') {
isDragging = false;
directionLock = null;
return;
}
isDragging = false;
const endX = (e.changedTouches && e.changedTouches[0].clientX) || startX;
const deltaX = endX - startX;
const elapsed = Math.max(Date.now() - startTime, 1);
const velocity = Math.abs(deltaX) / elapsed;
const passedDistance = Math.abs(deltaX) > cardWidth * SWIPE_DISTANCE_RATIO;
const passedVelocity = velocity > SWIPE_VELOCITY_THRESHOLD;
let nextIndex = currentIndex;
if (passedDistance || passedVelocity) {
nextIndex = deltaX < 0 ? currentIndex + 1 : currentIndex - 1;
}
goToIndex(nextIndex);
directionLock = null;
}
// ── Enable / disable swipe mode ─────────────────────────
function enableSwipe() {
if (isSwipeActive) return;
isSwipeActive = true;
buildTrack();
measure();
goToIndex(0, false);
track.addEventListener('touchstart', onTouchStart, { passive: true });
track.addEventListener('touchmove', onTouchMove, { passive: false });
track.addEventListener('touchend', onTouchEnd);
track.addEventListener('touchcancel', onTouchEnd);
}
function disableSwipe() {
if (!isSwipeActive) return;
isSwipeActive = false;
if (track) {
track.removeEventListener('touchstart', onTouchStart);
track.removeEventListener('touchmove', onTouchMove);
track.removeEventListener('touchend', onTouchEnd);
track.removeEventListener('touchcancel', onTouchEnd);
}
teardownTrack();
currentIndex = 0;
}
function handleResize() {
const nowMobile = checkMobileRange();
if (nowMobile && !isSwipeActive) {
enableSwipe();
} else if (!nowMobile && isSwipeActive) {
disableSwipe();
} else if (nowMobile && isSwipeActive) {
// Still in range, but dimensions may have changed (e.g. rotation).
measure();
goToIndex(currentIndex, false);
}
}
// ── Init ────────────────────────────────────────────────
if (checkMobileRange()) {
enableSwipe();
}
window.addEventListener('resize', handleResize);
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ── Guard against double-init ──────────────────────────
if (window._lenisScrollInit) return;
window._lenisScrollInit = true;
// desktop only
const isDesktop = window.matchMedia("(min-width: 992px)").matches;
if (!isDesktop) return;
const lenisScript = document.createElement("script");
lenisScript.src = "https://unpkg.com/lenis@1.3.21/dist/lenis.min.js";
lenisScript.onload = function () {
if (typeof Lenis === "undefined") {
console.error("[LenisScroll] Lenis failed to load.");
return;
}
const lenis = new Lenis({
lerp: 0.07, // the smaller, the smoother
smoothWheel: true,
wheelMultiplier: 0.8,
autoRaf: false,
// Force document-level scroll measurement. Without this, an
// overflow:hidden element earlier in the DOM (e.g. a sticky
// section wrapper) can cause Lenis to under-measure total
// scrollHeight, cutting off content at the end of the page
// (footer) even though native scroll renders it fine.
wrapper: window,
content: document.documentElement
});
function raf(time) {
lenis.raf(time);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
// so it can be called from other scripts
window.lenis = lenis;
// ── Fix: keep scroll limit in sync with actual document height ──
// ResizeObserver on body catches any layout change that affects
// total scrollHeight, including the case where an overflow:hidden
// sticky wrapper earlier in the DOM causes Lenis to under-measure
// the page on first init. This covers font swaps, late layout
// shifts, and the sticky-wrap case together, instead of relying
// on a fixed timeout guess.
if (typeof ResizeObserver !== "undefined") {
const resizeObserver = new ResizeObserver(function () {
lenis.resize();
});
resizeObserver.observe(document.body);
} else {
// Fallback for browsers without ResizeObserver support
setTimeout(function () {
lenis.resize();
}, 500);
}
// ── Fix: keep scroll limit correct on viewport resize ──
window.addEventListener("resize", function () {
lenis.resize();
});
};
document.body.appendChild(lenisScript);
});
</script><script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ── Guard against double-init ──────────────────────────
if (window._serviceCardRotatorInit) return;
window._serviceCardRotatorInit = true;
// ── GSAP availability check ────────────────────────────
if (typeof gsap === "undefined") {
console.error("[ServiceCardRotator] GSAP not found.");
return;
}
// ── Config ──────────────────────────────────────────────
const AUTO_DURATION = 5; // seconds per card
const ACTIVE_CLASS = "is-active-service";
const ACCENT_COLOR = "#820308";
const DEFAULT_TITLE_COLOR = "var(--_colors---text-color--text-primary)";
// ── DOM setup ───────────────────────────────────────────
const tabLinks = Array.from(document.querySelectorAll(".service-tab-link"));
const panes = Array.from(document.querySelectorAll(".service-tab-pane"));
const contentWrap = document.querySelector(".tab-service-content");
if (!tabLinks.length || !panes.length) {
console.warn("[ServiceCardRotator] .service-tab-link or .service-tab-pane not found.");
return;
}
// Panes are absolutely positioned so all three can share the same box
// (required for autoAlpha crossfade). This needs a positioned ancestor —
// set it here defensively in case .tab-service-content isn't already
// position:relative in the Webflow stylesheet.
if (contentWrap) {
const currentPosition = getComputedStyle(contentWrap).position;
if (currentPosition === "static") {
gsap.set(contentWrap, { position: "relative" });
}
} else {
console.warn("[ServiceCardRotator] .tab-service-content not found — pane absolute positioning may misplace elements.");
}
// Build a lookup array of card data, one entry per tab link / pane pair
const cards = tabLinks.map(function (link, i) {
return {
link: link,
card: link.querySelector(".service-card"),
title: link.querySelector(".title-services-card"),
description: link.querySelector(".services-description"),
arrow: link.querySelector(".service-arrow"),
accentLine: link.querySelector(".line-service-card"),
borderLine: link.querySelector(".border-line-service"),
pane: panes[i] || null,
index: i
};
});
// ── Initial state ───────────────────────────────────────
cards.forEach(function (c) {
if (c.accentLine) gsap.set(c.accentLine, { scaleY: 0, transformOrigin: "top center" });
if (c.arrow) gsap.set(c.arrow, { autoAlpha: 0 });
if (c.description) gsap.set(c.description, { display: "block", autoAlpha: 0, height: 0, overflow: "hidden" });
if (c.borderLine) gsap.set(c.borderLine, { scaleX: 0, transformOrigin: "left center" });
if (c.title) gsap.set(c.title, { color: "" });
if (c.pane) gsap.set(c.pane, { display: "flex", position: "absolute", top: 0, left: 0, width: "100%", height: "100%", autoAlpha: 0 });
});
// ── State ───────────────────────────────────────────────
let activeIndex = 0; // index the master auto-rotate timer is currently on
let displayIndex = 0; // index currently shown visually
let borderTween = null;
let masterTimeout = null;
// ── Apply visual "active" state to a given card index (does not touch the master timer) ──
function showCard(index) {
if (index === displayIndex && cards[index].card.classList.contains(ACTIVE_CLASS)) return;
displayIndex = index;
cards.forEach(function (c, i) {
const isActive = i === index;
c.card.classList.toggle(ACTIVE_CLASS, isActive);
if (c.title) {
gsap.to(c.title, {
color: isActive ? ACCENT_COLOR : DEFAULT_TITLE_COLOR,
duration: 0.3,
overwrite: "auto"
});
}
if (c.arrow) {
gsap.to(c.arrow, {
autoAlpha: isActive ? 1 : 0,
duration: 0.3,
overwrite: "auto"
});
}
if (c.accentLine) {
gsap.to(c.accentLine, {
scaleY: isActive ? 1 : 0,
duration: 0.4,
ease: "power2.out",
overwrite: "auto"
});
}
if (c.description) {
if (isActive) {
// Force visible + auto height to measure the natural height,
// then animate from 0 up to that measured value. Doing this
// avoids GSAP's inability to tween height starting from
// display:none (Webflow's default state for this class).
gsap.set(c.description, { display: "block", height: "auto" });
const naturalHeight = c.description.offsetHeight;
gsap.fromTo(
c.description,
{ height: 0, autoAlpha: 0 },
{
height: naturalHeight,
autoAlpha: 1,
duration: 0.4,
ease: "power2.out",
overwrite: "auto",
onComplete: function () {
gsap.set(c.description, { height: "auto" });
}
}
);
} else {
gsap.to(c.description, {
height: 0,
autoAlpha: 0,
duration: 0.3,
ease: "power2.inOut",
overwrite: "auto"
});
}
}
// Crossfade the matching image pane
if (c.pane) {
gsap.to(c.pane, {
autoAlpha: isActive ? 1 : 0,
duration: isActive ? 0.5 : 0.35,
ease: "power1.inOut",
overwrite: "auto"
});
}
});
}
// ── Border-line progress animation (only reflects the real master timer, not hover) ──
function playBorderProgress(index) {
if (borderTween) borderTween.kill();
cards.forEach(function (c) {
if (c.borderLine) gsap.set(c.borderLine, { scaleX: 0 });
});
const target = cards[index].borderLine;
if (!target) return;
borderTween = gsap.to(target, {
scaleX: 1,
duration: AUTO_DURATION,
ease: "none"
});
}
// ── Master auto-rotate loop ──────────────────────────────
// Advances activeIndex and restarts its own delayed call each cycle.
// goToIndex() (called on hover) also restarts this cycle from scratch,
// so hovering a card makes it the new anchor for auto-rotation —
// it does not get overridden when the timer next fires.
function goToNext() {
goToIndex((activeIndex + 1) % cards.length);
}
function goToIndex(index) {
if (masterTimeout) masterTimeout.kill();
activeIndex = index;
showCard(activeIndex);
playBorderProgress(activeIndex);
masterTimeout = gsap.delayedCall(AUTO_DURATION, goToNext);
}
// ── Hover handlers ────────────────────────────────────────
// Hovering a card claims it as the active card permanently (until the
// next hover or the next natural rotation) — moving the cursor away
// does NOT revert to the previous card.
cards.forEach(function (c) {
c.link.addEventListener("mouseenter", function () {
goToIndex(c.index);
});
});
// ── Init ────────────────────────────────────────────────
goToIndex(activeIndex);
});
</script>