<p> </p>
<p><link href="https://fonts.googleapis.com" rel="preconnect" /> <link href="https://fonts.gstatic.com" rel="preconnect" crossorigin="" /> <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet" /></p>
<style>
/* ============================================================
REPET TOFU CAT LITTER — Full-Screen Video Overlay Landing Page
============================================================ */
:root {
--bg: #000000;
--accent: #d4b896;
--accent-gold: #c9a96e;
--text-primary: #f0ece4;
--text-muted: #7a7470;
--text-dim: #3d3a38;
--font-display: 'Cormorant Garamond', serif;
--font-body: 'DM Sans', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--bg); }
body {
background: var(--bg);
color: var(--text-primary);
font-family: var(--font-body);
font-weight: 300;
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
/* ---- Loader ---- */
#loader {
position: fixed; inset: 0; z-index: 1000;
background: var(--bg);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2rem;
transition: opacity 0.7s ease;
}
#loader.hidden { opacity: 0; pointer-events: none; }
.loader-brand {
font-family: var(--font-display);
font-size: clamp(1.6rem, 4vw, 2.4rem);
font-weight: 300; letter-spacing: 0.5em;
color: var(--accent); text-transform: uppercase;
}
.loader-bar-wrap {
width: clamp(200px, 28vw, 340px); height: 1px;
background: var(--text-dim); overflow: hidden;
}
#loader-bar { height: 100%; width: 0%; background: var(--accent); transition: width 0.1s linear; }
#loader-percent { font-size: 0.68rem; letter-spacing: 0.22em; color: var(--text-muted); }
/* ---- Header ---- */
.site-header {
position: fixed; top: 0; left: 0; right: 0;
z-index: 200; padding: 1.4rem 3rem;
mix-blend-mode: difference;
}
nav { display: flex; align-items: center; justify-content: space-between; }
.nav-logo {
font-family: var(--font-display); font-size: 1rem;
font-weight: 600; letter-spacing: 0.45em; color: #fff;
}
.nav-links { display: flex; list-style: none; gap: 2rem; align-items: center; }
.nav-links a {
font-size: 0.7rem; font-weight: 400;
letter-spacing: 0.15em; text-transform: uppercase;
color: #fff; opacity: 0.55; transition: opacity 0.3s;
}
.nav-links a:hover { opacity: 1; }
.nav-story { opacity: 0.75 !important; }
.nav-cta {
opacity: 1 !important;
border: 1px solid rgba(255,255,255,0.45) !important;
padding: 0.45rem 1.1rem !important; border-radius: 2px;
transition: background 0.3s, color 0.3s !important;
}
.nav-cta:hover { background: #fff !important; color: #000 !important; }
/* ---- Canvas — full-screen, visible from load ---- */
.canvas-wrap { position: fixed; inset: 0; z-index: 10; }
canvas { display: block; width: 100%; height: 100%; }
/* ---- Dark Overlay ---- */
#dark-overlay {
position: fixed; inset: 0; z-index: 15;
background: #000; opacity: 0; pointer-events: none;
}
/* ---- Marquee ---- */
.marquee-wrap {
position: fixed; bottom: 2.5rem; left: 0; right: 0;
z-index: 20; overflow: hidden; opacity: 0; pointer-events: none;
}
.marquee-text {
font-family: var(--font-display);
font-size: clamp(4rem, 8vw, 7.5rem);
font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
color: transparent;
-webkit-text-stroke: 1px rgba(212, 184, 150, 0.18);
white-space: nowrap; will-change: transform; line-height: 1;
}
/* ---- Hero — full-screen transparent overlay, text left ---- */
.hero-standalone {
position: relative; z-index: 100;
width: 100%; height: 100vh;
background: transparent;
display: flex; flex-direction: column; justify-content: center;
padding-left: 6vw; padding-right: 50vw;
overflow: hidden;
}
/* Gradient so hero text is readable over any video frame */
.hero-standalone::before {
content: '';
position: absolute; inset: 0;
background: linear-gradient(to right, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
z-index: 0; pointer-events: none;
}
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 1.6rem; }
.hero-heading {
font-family: var(--font-display); font-weight: 700;
line-height: 0.88; color: var(--text-primary);
display: flex; flex-direction: column; gap: 0.08em;
}
.hero-brand {
font-size: clamp(5rem, 12vw, 11rem);
letter-spacing: 0.06em; text-transform: uppercase;
text-shadow: 0 2px 30px rgba(0,0,0,0.6);
}
.hero-sub {
font-size: clamp(1.3rem, 3.2vw, 3.2rem);
letter-spacing: 0.22em; font-weight: 300;
color: var(--accent); text-transform: uppercase;
}
.hero-tagline {
font-family: var(--font-display); font-style: italic;
font-size: clamp(1.1rem, 2vw, 1.8rem); font-weight: 300;
color: rgba(240,236,228,0.65); letter-spacing: 0.02em; line-height: 1.5;
}
.section-label {
display: block; font-size: 0.64rem; font-weight: 400;
letter-spacing: 0.24em; text-transform: uppercase;
color: var(--text-muted); font-family: var(--font-body);
}
.hero-content .section-label { color: var(--accent-gold); opacity: 0.85; letter-spacing: 0.3em; }
.scroll-indicator {
position: absolute; bottom: 2.8rem; left: 6vw;
display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem;
opacity: 0.35; animation: bob 2s ease-in-out infinite; z-index: 1;
}
.scroll-indicator span { font-size: 0.58rem; letter-spacing: 0.28em; text-transform: uppercase; }
.scroll-indicator svg { width: 15px; height: 15px; }
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(6px); }
}
/* ---- Scroll Container ---- */
#scroll-container { position: relative; height: 700vh; z-index: 20; }
/* ---- Scroll Sections ---- */
.scroll-section {
position: absolute; left: 0; right: 0; top: 50%;
transform: translateY(-50%);
pointer-events: none; opacity: 0; visibility: hidden; z-index: 25;
}
.scroll-section.is-visible { opacity: 1; visibility: visible; pointer-events: auto; }
/* Left-zone text with gradient backdrop for video contrast */
.align-left {
padding-left: 6vw;
padding-right: 50vw;
/* Gradient fades left-to-transparent so text reads on any video frame */
background: linear-gradient(to right, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.45) 55%, transparent 100%);
}
.align-left .section-inner { max-width: 44vw; }
.section-inner { display: flex; flex-direction: column; gap: 1.5rem; }
.section-heading {
font-family: var(--font-display);
font-size: clamp(3.2rem, 6vw, 6.5rem);
font-weight: 700; line-height: 0.95;
color: var(--text-primary); letter-spacing: -0.01em;
text-shadow: 0 2px 24px rgba(0,0,0,0.5);
}
.section-heading em { font-style: italic; color: var(--accent); font-weight: 300; }
.section-body {
font-size: clamp(0.9rem, 1.2vw, 1.05rem);
line-height: 1.82; color: rgba(240,236,228,0.72); font-weight: 300;
}
/* Feature list styling */
.section-list {
list-style: none; display: flex; flex-direction: column; gap: 0.7rem;
padding: 0;
}
.section-list li {
font-size: clamp(0.82rem, 1.1vw, 0.98rem);
line-height: 1.6; color: rgba(240,236,228,0.68); font-weight: 300;
padding-left: 1.2em; position: relative;
}
.section-list li::before {
content: '—'; position: absolute; left: 0;
color: var(--accent); font-weight: 400;
}
.section-list li strong { color: var(--text-primary); font-weight: 500; }
/* ---- Stats Section ---- */
.section-stats {
padding: 0 8vw; display: flex;
align-items: center; justify-content: center;
left: 0; right: 0;
}
.stats-grid { display: flex; gap: clamp(3rem, 8vw, 9rem); align-items: flex-end; }
.stat { display: flex; flex-direction: column; align-items: center; gap: 0.65rem; }
.stat-row { display: flex; align-items: flex-start; line-height: 1; }
.stat-number {
font-family: var(--font-display); font-size: clamp(4rem, 9vw, 9rem);
font-weight: 700; color: var(--accent); line-height: 0.9; letter-spacing: -0.02em;
}
.stat-suffix {
font-family: var(--font-display); font-size: clamp(1.4rem, 3vw, 3rem);
font-weight: 300; color: var(--accent);
padding-top: 0.35em; margin-left: 0.08em; opacity: 0.75;
}
.stat-label { font-size: 0.6rem; letter-spacing: 0.24em; text-transform: uppercase; color: var(--text-muted); }
/* ---- CTA Section ---- */
.section-cta {
left: 6vw; right: auto; width: auto; max-width: 46vw; padding: 0;
text-align: left; transform: translateY(-50%);
background: transparent;
}
.cta-inner { align-items: flex-start; text-align: left; }
.cta-heading { font-size: clamp(3rem, 5.5vw, 6rem); }
.cta-buttons { display: flex; gap: 1.2rem; align-items: center; margin-top: 0.4rem; flex-wrap: wrap; }
.cta-button {
display: inline-block; padding: 1.1rem 2.8rem;
background: var(--accent); color: #000;
font-family: var(--font-body); font-size: 0.78rem; font-weight: 500;
letter-spacing: 0.2em; text-transform: uppercase;
border-radius: 2px; cursor: pointer;
transition: background 0.3s, transform 0.2s;
}
.cta-button:hover { background: var(--accent-gold); transform: translateY(-2px); }
.cta-button-ghost {
display: inline-block; padding: 1.1rem 0;
color: var(--accent); font-family: var(--font-body);
font-size: 0.78rem; font-weight: 400; letter-spacing: 0.18em;
text-transform: uppercase; opacity: 0.8;
transition: opacity 0.3s, letter-spacing 0.3s;
}
.cta-button-ghost:hover { opacity: 1; letter-spacing: 0.25em; }
.cta-note { font-size: 0.66rem; letter-spacing: 0.1em; color: var(--text-muted); margin-top: 0.3rem; }
/* ---- Mobile ---- */
@media (max-width: 768px) {
.site-header { padding: 1.2rem 1.5rem; }
.nav-links li:nth-child(1), .nav-links li:nth-child(2) { display: none; }
.hero-standalone { padding-left: 5vw; padding-right: 5vw; justify-content: flex-end; padding-bottom: 10vh; }
.hero-standalone::before { background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.3) 60%, transparent 100%); }
.hero-brand { font-size: clamp(3.5rem, 18vw, 7rem); }
.hero-sub { font-size: clamp(1rem, 4.5vw, 1.8rem); }
.align-left { padding-left: 5vw; padding-right: 5vw; background: rgba(0,0,0,0.78); }
.align-left .section-inner { max-width: 100%; padding: 1.6rem; }
#scroll-container { height: 520vh; }
.stats-grid { gap: 2.5rem; flex-wrap: wrap; justify-content: center; }
.section-cta { left: 5vw; max-width: 92vw; }
.marquee-text { font-size: 14vw; }
}
</style>
<!-- LOADER -->
<div id="loader">
<div class="loader-brand">REPET</div>
<div class="loader-bar-wrap">
<div id="loader-bar"></div>
</div>
<div id="loader-percent">0%</div>
</div>
<!-- FIXED HEADER --><header class="site-header"><nav><span class="nav-logo">REPET</span>
<ul class="nav-links">
<li><a href="https://repet.store/pages/why-tofu.html">Why Tofu?</a></li>
<li><a href="https://repet.store/pages/reviews.html">Reviews</a></li>
<li><a class="nav-story" href="https://repet.store/pages/brand.html">Brand Story</a></li>
<li><a class="nav-cta" href="https://repet.store/pages/product.html">Shop Now</a></li>
</ul>
</nav></header><!-- CANVAS — full-screen, visible immediately -->
<div id="canvas-wrap" class="canvas-wrap"><canvas id="canvas"></canvas></div>
<!-- DARK OVERLAY -->
<div id="dark-overlay"></div>
<!-- MARQUEE -->
<div id="marquee-wrap" class="marquee-wrap" data-scroll-speed="-30">
<div class="marquee-text">PEA FIBERS · CORN STARCH · GUAR GUM · 99.9% DUST FREE · FLUSHABLE · PEA FIBERS · CORN STARCH · GUAR GUM · 99.9% DUST FREE · FLUSHABLE · </div>
</div>
<!-- HERO — transparent overlay, left-side text -->
<section class="hero-standalone">
<div class="hero-content"><span class="section-label">TikTok's Top Pick · Born in London</span>
<h1 class="hero-heading"><span class="hero-word hero-brand">REPET</span> <span class="hero-word hero-sub">TOFU CAT LITTER</span></h1>
<p class="hero-tagline">S-Grade purity.<br />Zero compromise.</p>
</div>
<div class="scroll-indicator">Scroll</div>
</section>
<!-- SCROLL CONTAINER -->
<div id="scroll-container"><!-- SECTION 1: Raw Materials — slide-left, align-left -->
<section class="scroll-section section-content align-left" data-enter="20" data-leave="36" data-animation="slide-left">
<div class="section-inner"><span class="section-label">001 / Raw Materials</span>
<h2 class="section-heading">Food-grade<br /><em>all the way.</em></h2>
<p class="section-body">Pea fibers, corn starch, guar gum. S-Grade natural ingredients held to food-safety standards — the same purity your kitchen demands. Zero clay. Zero silica. Zero toxins.</p>
</div>
</section>
<!-- SECTION 2: Product Features — fade-up, align-left -->
<section class="scroll-section section-content align-left" data-enter="38" data-leave="53" data-animation="fade-up">
<div class="section-inner"><span class="section-label">002 / Formula</span>
<h2 class="section-heading">Five-way<br /><em>protection.</em></h2>
<ul class="section-list">
<li><strong>Strong adsorption</strong> — clumps on contact, easy to scoop</li>
<li><strong>Efficient deodorising</strong> — leaves no odor, fresher home</li>
<li><strong>Safe & hygienic</strong> — non-toxic, even food-safe if ingested</li>
<li><strong>Low dust</strong> — professional process, clean air & paws</li>
<li><strong>Flushable</strong> — dissolves in water, pour straight down the toilet</li>
</ul>
</div>
</section>
<!-- SECTION 3: Stats — stagger-up, dark overlay -->
<section class="scroll-section section-stats" data-enter="54" data-leave="69" data-animation="stagger-up">
<div class="stats-grid">
<div class="stat">
<div class="stat-row"><span class="stat-number" data-value="99" data-decimals="0">0</span> <span class="stat-suffix">.9%</span></div>
<span class="stat-label">Dust-Free</span></div>
<div class="stat">
<div class="stat-row"><span class="stat-number" data-value="7" data-decimals="0">0</span> <span class="stat-suffix">L</span></div>
<span class="stat-label">Per Bag</span></div>
<div class="stat">
<div class="stat-row"><span class="stat-number" data-value="2" data-decimals="0">0</span> <span class="stat-suffix">yr</span></div>
<span class="stat-label">Shelf Life</span></div>
</div>
</section>
<!-- SECTION 4: Eco / Flushable — clip-reveal, align-left -->
<section class="scroll-section section-content align-left" data-enter="69" data-leave="82" data-animation="clip-reveal">
<div class="section-inner"><span class="section-label">003 / Eco-Friendly</span>
<h2 class="section-heading">Scoop.<br /><em>Flush. Done.</em></h2>
<p class="section-body">Lay 4–6 cm in a clean litter box. After use, scoop the clumps straight into the toilet — tofu litter dissolves completely. Replace fully once a month. No landfill. No plastic bags. No unnecessary waste.</p>
</div>
</section>
<!-- CTA — scale-up, persist -->
<section class="scroll-section section-cta" data-enter="83" data-leave="97" data-animation="scale-up" data-persist="true">
<div class="section-inner cta-inner"><span class="section-label">All-Natural · Safe · Flushable · Born in London</span>
<h2 class="section-heading cta-heading">Give your cat<br />the <em>pure</em> choice.</h2>
<div class="cta-buttons"><a class="cta-button" href="https://repet.store/pages/product.html">Shop REPET Now</a> <a class="cta-button-ghost" href="https://repet.store/pages/brand.html">Our Story →</a></div>
<p class="cta-note">Product Spec: 7L · Shelf Life: 2 years · Ultron Digital & Technology Co., Ltd</p>
</div>
</section>
</div>
<!-- end #scroll-container -->
<script src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script>
/* ============================================================
TOFU CAT LITTER — Scroll-Driven Animation Engine
============================================================ */
const FRAME_COUNT = 151;
const FRAME_SPEED = 1.0; // animation spans full scroll so video shows top to bottom
const IMAGE_SCALE = 0.88;
const TOTAL_HEIGHT = 700; // vh — shorter page, tighter pacing
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const canvasWrap = document.getElementById('canvas-wrap');
const overlay = document.getElementById('dark-overlay');
const loader = document.getElementById('loader');
const loaderBar = document.getElementById('loader-bar');
const loaderPct = document.getElementById('loader-percent');
const heroSection = document.querySelector('.hero-standalone');
const scrollCont = document.getElementById('scroll-container');
const marqueeWrap = document.getElementById('marquee-wrap');
const frames = new Array(FRAME_COUNT);
let currentFrame = 0;
let bgColor = '#000000';
let bgSampleCache = {};
// ---- Canvas DPR Setup ----
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // reset transform before applying DPR
if (frames[currentFrame]) drawFrame(currentFrame);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// ---- Background Color Sampler ----
function sampleBgColor(img) {
const sampleCanvas = document.createElement('canvas');
sampleCanvas.width = img.naturalWidth;
sampleCanvas.height = img.naturalHeight;
const sCtx = sampleCanvas.getContext('2d');
sCtx.drawImage(img, 0, 0);
const iw = img.naturalWidth;
const ih = img.naturalHeight;
const samples = [];
const pts = [
[0, 0], [iw - 1, 0],
[0, ih - 1], [iw - 1, ih - 1],
[0, Math.floor(ih / 2)], [iw - 1, Math.floor(ih / 2)],
[Math.floor(iw / 2), 0], [Math.floor(iw / 2), ih - 1]
];
pts.forEach(([x, y]) => {
try {
const d = sCtx.getImageData(x, y, 1, 1).data;
samples.push([d[0], d[1], d[2]]);
} catch (e) {}
});
if (!samples.length) return '#000000';
const avg = samples.reduce((a, b) => [a[0]+b[0], a[1]+b[1], a[2]+b[2]], [0,0,0])
.map(v => Math.round(v / samples.length));
// darken slightly to help blend with pure-black UI
const darken = 0.6;
const r = Math.round(avg[0] * darken);
const g = Math.round(avg[1] * darken);
const b = Math.round(avg[2] * darken);
return `rgb(${r},${g},${b})`;
}
// ---- Draw a single image onto canvas (full-screen cover, centered for 16:9 video) ----
function drawImageOnCanvas(img, alpha) {
const cw = window.innerWidth;
const ch = window.innerHeight;
const iw = img.naturalWidth;
const ih = img.naturalHeight;
// Cover mode: fill entire viewport, keep aspect ratio, center the image
const scale = Math.max(cw / iw, ch / ih);
const dw = iw * scale;
const dh = ih * scale;
const dx = (cw - dw) / 2;
const dy = (ch - dh) / 2;
ctx.globalAlpha = alpha;
ctx.drawImage(img, dx, dy, dw, dh);
ctx.globalAlpha = 1.0;
}
// ---- Draw Frame ----
function drawFrame(index) {
const img = frames[index];
if (!img || !img.complete) return;
const cw = window.innerWidth;
const ch = window.innerHeight;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, cw, ch);
drawImageOnCanvas(img, 1.0);
}
// ---- Two-Phase Frame Preloader ----
function loadFrames() {
const FIRST_BATCH = 12;
let loaded = 0;
function updateLoader(n) {
loaded = n;
const pct = Math.round((loaded / FRAME_COUNT) * 100);
loaderBar.style.width = pct + '%';
loaderPct.textContent = pct + '%';
}
function frameUrl(i) {
return `https://img-va.myshopline.com/image/store/1749217664136/frame-${String(i).padStart(4, '0')}.webp`;
}
function loadOne(i) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
frames[i - 1] = img;
if ((i - 1) % 20 === 0 && !bgSampleCache[i]) {
bgSampleCache[i] = true;
bgColor = sampleBgColor(img);
}
resolve();
};
img.onerror = resolve;
img.src = frameUrl(i);
});
}
// Phase 1: first batch — fast first paint
const phase1 = Array.from({length: FIRST_BATCH}, (_, k) => loadOne(k + 1));
Promise.all(phase1).then(() => {
updateLoader(FIRST_BATCH);
drawFrame(0);
// Phase 2: remaining frames in background
let remaining = FIRST_BATCH + 1;
function loadNext() {
if (remaining > FRAME_COUNT) {
// All done — hide loader
updateLoader(FRAME_COUNT);
setTimeout(() => {
loader.classList.add('hidden');
setTimeout(() => { loader.style.display = 'none'; }, 700);
initAll();
}, 300);
return;
}
loadOne(remaining).then(() => {
updateLoader(remaining);
remaining++;
loadNext();
});
}
loadNext();
});
}
// ---- Init All Systems After Load ----
function initAll() {
initLenis();
initHeroEntrance();
initHeroTransition();
initFrameScroll();
initDarkOverlay(0.52, 0.70);
initMarquee();
initSections();
initCounters();
}
// ---- Hero Entrance (stagger after load, canvas visible immediately) ----
function initHeroEntrance() {
// Canvas is visible from frame 1 — no circle-wipe needed
canvasWrap.style.clipPath = 'none';
const words = document.querySelectorAll('.hero-word');
const label = document.querySelector('.hero-content .section-label');
const tagline = document.querySelector('.hero-tagline');
const scrollInd = document.querySelector('.scroll-indicator');
gsap.set([label, words, tagline, scrollInd], { opacity: 0, y: 24 });
const tl = gsap.timeline({ delay: 0.3 });
tl.to(label, { opacity: 1, y: 0, duration: 0.7, ease: 'power3.out' })
.to(words, { opacity: 1, y: 0, duration: 0.9, ease: 'power3.out', stagger: 0.14 }, '-=0.3')
.to(tagline, { opacity: 1, y: 0, duration: 0.7, ease: 'power3.out' }, '-=0.3')
.to(scrollInd,{ opacity: 0.35, y: 0, duration: 0.6, ease: 'power2.out' }, '-=0.2');
}
// ---- 6a. Lenis Smooth Scroll ----
function initLenis() {
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true
});
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
}
// ---- Hero Fade-Out on Scroll ----
function initHeroTransition() {
ScrollTrigger.create({
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
const p = self.progress;
// Hero text fades out in first 12% of scroll
heroSection.style.opacity = Math.max(0, 1 - p * 12);
}
});
}
// ---- 6d. Frame-to-Scroll Binding ----
function initFrameScroll() {
ScrollTrigger.create({
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
const accelerated = Math.min(self.progress * FRAME_SPEED, 1);
const index = Math.min(Math.floor(accelerated * FRAME_COUNT), FRAME_COUNT - 1);
if (index !== currentFrame) {
currentFrame = index;
requestAnimationFrame(() => {
if (frames[currentFrame]) {
if (currentFrame % 20 === 0 && frames[currentFrame]) {
const key = currentFrame + 1;
if (!bgSampleCache[key]) {
bgSampleCache[key] = true;
bgColor = sampleBgColor(frames[currentFrame]);
}
}
drawFrame(currentFrame);
}
});
}
}
});
}
// ---- 6h. Dark Overlay ----
function initDarkOverlay(enter, leave) {
const fadeRange = 0.04;
ScrollTrigger.create({
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
const p = self.progress;
let opacity = 0;
if (p >= enter - fadeRange && p <= enter) {
opacity = (p - (enter - fadeRange)) / fadeRange;
} else if (p > enter && p < leave) {
opacity = 0.90;
} else if (p >= leave && p <= leave + fadeRange) {
opacity = 0.90 * (1 - (p - leave) / fadeRange);
}
overlay.style.opacity = opacity;
}
});
}
// ---- 6g. Horizontal Marquee ----
function initMarquee() {
const speed = parseFloat(marqueeWrap.dataset.scrollSpeed) || -28;
// Fade marquee in when canvas is visible, out near end
ScrollTrigger.create({
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
const p = self.progress;
let mOpacity = 0;
if (p > 0.12 && p < 0.85) {
mOpacity = Math.min(1, (p - 0.12) / 0.06) * Math.min(1, (0.85 - p) / 0.06);
}
marqueeWrap.style.opacity = mOpacity;
}
});
gsap.to(marqueeWrap.querySelector('.marquee-text'), {
xPercent: speed,
ease: 'none',
scrollTrigger: {
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: true
}
});
}
// ---- 6e. Section Animation System ----
function initSections() {
const sections = document.querySelectorAll('.scroll-section');
const containerHeight = scrollCont.offsetHeight;
sections.forEach(section => {
const enter = parseFloat(section.dataset.enter) / 100;
const leave = parseFloat(section.dataset.leave) / 100;
const type = section.dataset.animation;
const persist = section.dataset.persist === 'true';
const mid = (enter + leave) / 2;
// Position section at midpoint of its enter/leave range within 900vh container
section.style.top = (mid * TOTAL_HEIGHT) + 'vh';
const children = section.querySelectorAll(
'.section-label, .section-heading, .section-body, .section-note, .section-list li, .cta-button, .cta-button-ghost, .cta-note, .stat'
);
// Build entrance timeline
const tl = gsap.timeline({ paused: true });
switch (type) {
case 'slide-left':
gsap.set(children, { x: -80, opacity: 0 });
tl.to(children, { x: 0, opacity: 1, stagger: 0.14, duration: 0.9, ease: 'power3.out' });
break;
case 'slide-right':
gsap.set(children, { x: 80, opacity: 0 });
tl.to(children, { x: 0, opacity: 1, stagger: 0.14, duration: 0.9, ease: 'power3.out' });
break;
case 'fade-up':
gsap.set(children, { y: 50, opacity: 0 });
tl.to(children, { y: 0, opacity: 1, stagger: 0.12, duration: 0.9, ease: 'power3.out' });
break;
case 'stagger-up':
gsap.set(children, { y: 60, opacity: 0 });
tl.to(children, { y: 0, opacity: 1, stagger: 0.15, duration: 0.8, ease: 'power3.out' });
break;
case 'scale-up':
gsap.set(children, { scale: 0.85, opacity: 0 });
tl.to(children, { scale: 1, opacity: 1, stagger: 0.12, duration: 1.0, ease: 'power2.out' });
break;
case 'clip-reveal':
gsap.set(children, { clipPath: 'inset(100% 0 0 0)', opacity: 0 });
tl.to(children, { clipPath: 'inset(0% 0 0 0)', opacity: 1, stagger: 0.15, duration: 1.2, ease: 'power4.inOut' });
break;
default:
gsap.set(children, { y: 40, opacity: 0 });
tl.to(children, { y: 0, opacity: 1, stagger: 0.12, duration: 0.9, ease: 'power3.out' });
}
let isVisible = false;
let hasAnimated = false;
ScrollTrigger.create({
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: false,
onUpdate: (self) => {
const p = self.progress;
const inRange = p >= enter && p <= leave;
if (inRange && !isVisible) {
isVisible = true;
section.classList.add('is-visible');
if (!hasAnimated) {
hasAnimated = true;
tl.play();
} else {
tl.play();
}
} else if (!inRange && isVisible) {
if (!persist) {
isVisible = false;
section.classList.remove('is-visible');
tl.reverse();
}
}
}
});
});
}
// ---- 6f. Counter Animations ----
function initCounters() {
const statSection = document.querySelector('.section-stats');
if (!statSection) return;
const enter = parseFloat(statSection.dataset.enter) / 100;
const leave = parseFloat(statSection.dataset.leave) / 100;
let countersPlayed = false;
let countersReversed = true;
const counterEls = document.querySelectorAll('.stat-number');
function playCounters() {
counterEls.forEach(el => {
const target = parseFloat(el.dataset.value);
const decimals = parseInt(el.dataset.decimals || '0');
gsap.fromTo(el,
{ textContent: 0 },
{
textContent: target,
duration: 2.2,
ease: 'power1.out',
snap: { textContent: decimals === 0 ? 1 : 0.01 },
onUpdate: function() {
const v = parseFloat(this.targets()[0].textContent) || 0;
el.textContent = decimals === 0 ? Math.round(v) : v.toFixed(decimals);
}
}
);
});
}
function resetCounters() {
counterEls.forEach(el => { el.textContent = '0'; });
}
ScrollTrigger.create({
trigger: scrollCont,
start: 'top top',
end: 'bottom bottom',
scrub: false,
onUpdate: (self) => {
const p = self.progress;
const inRange = p >= enter && p <= leave;
if (inRange && !countersPlayed) {
countersPlayed = true;
countersReversed = false;
playCounters();
} else if (!inRange && !countersReversed) {
countersPlayed = false;
countersReversed = true;
resetCounters();
}
}
});
}
// ---- Kick Off ----
loadFrames();
</script>