Skip to content
About me
I turn
chaos
into
order
Scroll
Rebirth
World-Building | Art & Illustration
Explore
Pendle Finance
Defi | Web-design | UIUX | Illustrations | Branding
Explore
Pendle Illustrations
Art & Illustration
Explore
Boros Finance
Defi | Web-design | UIUX | Illustrations | Branding
Explore
Rebirth
World-Building | Architecture | Art & Illustrations
Enter
Pendle Finance
Defi | Web-design | UIUX | Illustrations | Branding
Enter
Pendle Illustrations
Art & Illustration
Enter
Thetanuts Finance
Defi | Web-design | UIUX | Branding
Enter
Art & Illustration
Branding
Narrative
UI & UX
Web Design
All
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); })();