Instruction
Webflow Template User Guide


.projects-list-wrap — marquee viewport. .w-dyn-items — Webflow Collection List track. .project-card-wrapper — project card used for hover pause.<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ==================================================
// PROJECTS MARQUEE (INFINITE LOOP + HOVER PAUSE)
// ==================================================
// ── Guard against double-init ─────────────────────
if (window._projectsMarqueeInit) return;
window._projectsMarqueeInit = true;
// ── GSAP availability check ───────────────────────
if (typeof gsap === "undefined") {
console.error("[ProjectsMarquee] GSAP not found.");
return;
}
// ── DOM elements ──────────────────────────────────
const viewport = document.querySelector(".projects-list-wrap");
if (!viewport) {
console.warn("[ProjectsMarquee] .projects-list-wrap not found.");
return;
}
// Find the Webflow Collection List inside the projects wrapper.
const track = viewport.querySelector(".w-dyn-items");
if (!track) {
console.warn("[ProjectsMarquee] Collection List not found.");
return;
}
// Get original project items.
const originalItems = Array.from(track.children);
if (originalItems.length === 0) {
console.warn("[ProjectsMarquee] No project items found.");
return;
}
// ── Configuration ─────────────────────────────────
const SPEED = 80;
// ── Prepare viewport ──────────────────────────────
viewport.style.overflow = "hidden";
// ── Prepare marquee track ─────────────────────────
track.style.display = "flex";
track.style.flexWrap = "nowrap";
track.style.width = "max-content";
track.style.willChange = "transform";
// Prevent Webflow Collection List items from shrinking.
originalItems.forEach(function (item) {
item.style.flex = "0 0 auto";
});
// ── Duplicate project items ───────────────────────
originalItems.forEach(function (item) {
const clone = item.cloneNode(true);
clone.setAttribute(
"data-marquee-clone",
"true"
);
track.appendChild(clone);
});
// ── Calculate loop distance ───────────────────────
let loopWidth = 0;
let marqueeTween = null;
function measure() {
// The original items occupy the first half
// of the duplicated track.
loopWidth = 0;
originalItems.forEach(function (item) {
loopWidth += item.getBoundingClientRect().width;
});
// Include the gaps between collection items.
const styles = window.getComputedStyle(track);
const gap =
parseFloat(styles.columnGap) ||
parseFloat(styles.gap) ||
0;
loopWidth += gap * originalItems.length;
}
// ── Create marquee animation ──────────────────────
function createMarquee() {
if (marqueeTween) {
marqueeTween.kill();
}
measure();
if (!loopWidth) return;
const duration = loopWidth / SPEED;
marqueeTween = gsap.fromTo(
track,
{
x: 0
},
{
x: -loopWidth,
duration: duration,
ease: "none",
repeat: -1
}
);
}
// ── Hover pause / resume ──────────────────────────
function setupHoverPause() {
const cards = track.querySelectorAll(
".project-card-wrapper"
);
cards.forEach(function (card) {
card.addEventListener(
"mouseenter",
function () {
if (marqueeTween) {
marqueeTween.pause();
}
}
);
card.addEventListener(
"mouseleave",
function () {
if (marqueeTween) {
marqueeTween.resume();
}
}
);
});
}
// ── Initialise ────────────────────────────────────
createMarquee();
setupHoverPause();
// ── Handle resize ─────────────────────────────────
let resizeTimer;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
createMarquee();
}, 200);
});
});
</script>.cta-section-wrapper — CTA interaction area. .circle-cta — animated CTA circles.<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ==================================================
// CTA CIRCLES - SUBTLE CURSOR REACTION
// ==================================================
if (window._ctaCircleInit) return;
window._ctaCircleInit = true;
if (typeof gsap === "undefined") {
console.error("[CTACircles] GSAP not found.");
return;
}
const wrapper = document.querySelector(".cta-section-wrapper");
const circles = document.querySelectorAll(".circle-cta");
if (!wrapper || !circles.length) {
console.warn("[CTACircles] Elements not found.");
return;
}
let active = false;
// ── Prepare circles ───────────────────────────────
circles.forEach(function (circle) {
gsap.set(circle, {
transformPerspective: 1000,
transformOrigin: "center center"
});
});
// ── Smooth motion ─────────────────────────────────
const moveX = [];
const moveY = [];
const rotateX = [];
const rotateY = [];
circles.forEach(function (circle, index) {
const strength = 0.7 + (index * 0.12);
moveX[index] = gsap.quickTo(circle, "x", {
duration: 0.8,
ease: "power3.out"
});
moveY[index] = gsap.quickTo(circle, "y", {
duration: 0.8,
ease: "power3.out"
});
rotateX[index] = gsap.quickTo(circle, "rotationX", {
duration: 0.9,
ease: "power3.out"
});
rotateY[index] = gsap.quickTo(circle, "rotationY", {
duration: 0.9,
ease: "power3.out"
});
});
// ── Enter CTA ─────────────────────────────────────
wrapper.addEventListener("mouseenter", function () {
active = true;
});
// ── Leave CTA ─────────────────────────────────────
wrapper.addEventListener("mouseleave", function () {
active = false;
circles.forEach(function (circle, index) {
moveX[index](0);
moveY[index](0);
rotateX[index](0);
rotateY[index](0);
});
});
// ── Cursor tracking ───────────────────────────────
window.addEventListener("mousemove", function (e) {
if (!active) return;
const rect = wrapper.getBoundingClientRect();
// Cursor position relative to CTA
// -1 = left/top
// 0 = center
// 1 = right/bottom
const mouseX =
((e.clientX - rect.left) / rect.width) * 2 - 1;
const mouseY =
((e.clientY - rect.top) / rect.height) * 2 - 1;
circles.forEach(function (circle, index) {
const strength =
0.7 + (index * 0.12);
// Very subtle movement
const x =
mouseX * 12 * strength;
const y =
mouseY * 12 * strength;
// Stronger "looking" / tilting effect
const rx =
-mouseY * 16 * strength;
const ry =
mouseX * 16 * strength;
moveX[index](x);
moveY[index](y);
rotateX[index](rx);
rotateY[index](ry);
});
});
});
</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>0 to their final value when the counter enters the viewport. Supports whole numbers, decimals, prefixes, and suffixes..counter-number — counter wrapper and ScrollTrigger target. The child text element can use any typography class such as .text-xxl, .text-xl, .text-lg, .text-md, or .text-sm.<script>
window.Webflow = window.Webflow || [];
window.Webflow.push(function () {
// ==================================================
// COUNTER NUMBER ANIMATION
// ==================================================
if (window._counterNumberInit) return;
window._counterNumberInit = true;
if (typeof gsap === "undefined") {
console.error("[CounterNumber] GSAP not found.");
return;
}
gsap.registerPlugin(ScrollTrigger);
document.querySelectorAll(".counter-number").forEach(function (counter) {
// Find the first text element inside the counter.
// Works with text-xxl, text-xl, text-lg, text-md, text-sm, etc.
const number = counter.firstElementChild;
if (!number) return;
const text = number.textContent.trim();
// Extract numeric value
const numericMatch = text.match(/[\d.]+/);
if (!numericMatch) return;
const numericText = numericMatch[0];
const finalValue = parseFloat(numericText);
// Count decimal places
const decimals =
(numericText.split(".")[1] || "").length;
// Preserve prefix and suffix
const numberStart =
text.indexOf(numericText);
const prefix =
text.substring(0, numberStart);
const suffix =
text.substring(
numberStart + numericText.length
);
// Animated value
const obj = {
value: 0
};
gsap.to(obj, {
value: finalValue,
duration: 2.8,
ease: "power3.out",
scrollTrigger: {
trigger: counter,
start: "top 88%",
once: true
},
onUpdate: function () {
number.textContent =
prefix +
obj.value.toFixed(decimals) +
suffix;
}
});
});
});
</script>