Skip to content
(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);
})();