I turn

chaos

into

order

Scroll
Rebirth
entering the e-waste recycling tower
Pendle Finance
dawn
rocket
Boros Finance
Boros Static Ver web

Boros

Boros Illustrations

Pendle

Alphageo

Thetanuts

Cedar

Ethene Labs Landing Page

Amulet

Pendle Illustrations

Fatum

Digital Eternity

Tan Brothers’ Insurance Branding

Tan Brothers’ Insurance Calendars

Rebirth

INTERFACE A real virtual experience

Climate Alpha Branding

Loners in Covid

Visual Poetry

Tan Brothers’ Insurance Website

Urbanity

Honest Wine

BO Architecture

Breathe

Kent Ridge Education

NUS Chinese Society Branding

Archlab

Urban Analytics Lab

(function () { const wrapper = document.querySelector('.hero-wrapper'); if (!wrapper) return; const video = wrapper.querySelector('.hero-video'); const lines = wrapper.querySelectorAll('.line'); const NUM_LINES = lines.length; const FADE_RATIO = 0.25; const LERP_SPEED = 0.12; let duration = 0; let targetTime = 0; let currentTime = 0; let ready = false; function initVideo() { duration = video.duration; targetTime = duration; currentTime = duration; video.currentTime = duration; ready = true; } video.addEventListener('loadedmetadata', initVideo); // Load the whole video into memory and play it from a blob URL. // This removes network latency from scroll-driven seeking — on a live // server, seeking via currentTime can stall waiting for byte-range // requests, causing jerky scrubbing even though it's smooth on // localhost. With a blob URL, every frame is already local. const remoteSrc = video.dataset.src; if (remoteSrc) { fetch(remoteSrc) .then((res) => res.blob()) .then((blob) => { video.src = URL.createObjectURL(blob); }) .catch(() => { video.src = remoteSrc; }); } // Cache layout measurements instead of reading them every animation // frame. getBoundingClientRect()/offsetHeight force a synchronous layout // recalculation; doing that every frame while also writing styles every // frame (the opacity/transform updates below) causes layout thrashing — // cheap on a tiny test page, but very expensive on a full WordPress page // with hundreds of elements, which is what causes scroll jank live. let total = 0; function measure() { const rect = wrapper.getBoundingClientRect(); const offsetTop = rect.top + window.scrollY; total = offsetTop + (wrapper.offsetHeight - window.innerHeight); } measure(); window.addEventListener('resize', measure); window.addEventListener('load', measure); function getProgress() { let p = total > 0 ? window.scrollY / total : 0; return Math.min(Math.max(p, 0), 1); } function updateText(progress) { const segment = 1 / NUM_LINES; lines.forEach((line, i) => { const start = i * segment; const end = start + segment; const fade = segment * FADE_RATIO; let opacity = 0; let shift = 12; if (progress >= start && progress <= end) { if (progress < start + fade) { const t = (progress - start) / fade; opacity = t; shift = 12 * (1 - t); } else if (progress > end - fade) { const t = (end - progress) / fade; opacity = t; shift = -12 * (1 - t); } else { opacity = 1; shift = 0; } } line.style.opacity = opacity; line.style.transform = `translate(-50%, calc(-50% + ${shift}px))`; }); } function loop() { if (ready) { const progress = getProgress(); targetTime = duration * (1 - progress); currentTime += (targetTime - currentTime) * LERP_SPEED; if (Math.abs(currentTime - video.currentTime) > 0.01) { video.currentTime = currentTime; } updateText(progress); } requestAnimationFrame(loop); } requestAnimationFrame(loop); })();