/* ============================================================ OTIS VANDERMEER — main.js Three.js ember-field hero + GSAP/Lenis choreography ============================================================ */ import * as THREE from "three"; document.documentElement.classList.add("js"); const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const isFinePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches; /* ------------------------------------------------------------ Boot: wait for fonts + DOM so SplitLines measures correctly ------------------------------------------------------------ */ const ready = Promise.all([ new Promise((res) => { if (document.readyState !== "loading") res(); else document.addEventListener("DOMContentLoaded", res, { once: true }); }), document.fonts ? document.fonts.ready : Promise.resolve(), ]); ready.then(init); function init() { gsap.registerPlugin(ScrollTrigger); const lenis = initSmoothScroll(); initClock(); initWebGL(); splitAll(); initPreloader(); initHeroIntro(); initScrollReveals(); initWorkList(); initMarquee(); initCounters(); initCursor(); initMagnetic(lenis); } /* ------------------------------------------------------------ Smooth scroll (Lenis) driven by GSAP ticker ------------------------------------------------------------ */ function initSmoothScroll() { if (prefersReduced || typeof Lenis === "undefined") return null; const lenis = new Lenis({ lerp: 0.1, smoothWheel: true }); lenis.on("scroll", ScrollTrigger.update); gsap.ticker.add((t) => lenis.raf(t * 1000)); gsap.ticker.lagSmoothing(0); // Anchor links go through Lenis document.querySelectorAll('a[href^="#"]').forEach((a) => { a.addEventListener("click", (e) => { const target = document.querySelector(a.getAttribute("href")); if (!target) return; e.preventDefault(); lenis.scrollTo(target, { offset: 0, duration: 1.4 }); }); }); return lenis; } /* ------------------------------------------------------------ Rotterdam clock ------------------------------------------------------------ */ function initClock() { const el = document.querySelector("[data-clock]"); if (!el) return; const fmt = new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Amsterdam", hour: "2-digit", minute: "2-digit", second: "2-digit", }); const tick = () => (el.textContent = fmt.format(new Date())); tick(); setInterval(tick, 1000); } /* ------------------------------------------------------------ Three.js — "ember field" shader plane ------------------------------------------------------------ */ function initWebGL() { const canvas = document.querySelector("[data-webgl]"); if (!canvas) return; let renderer; try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false }); } catch { canvas.remove(); // graceful: CSS veil still gives a dark hero return; } const isMobile = window.matchMedia("(max-width: 760px)").matches; renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.25 : 1.75)); const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const uniforms = { uTime: { value: 0 }, uRes: { value: new THREE.Vector2(1, 1) }, uMouse: { value: new THREE.Vector2(0.5, 0.5) }, uScroll: { value: 0 }, }; const material = new THREE.ShaderMaterial({ uniforms, vertexShader: /* glsl */ ` void main() { gl_Position = vec4(position, 1.0); } `, fragmentShader: /* glsl */ ` precision highp float; uniform float uTime; uniform vec2 uRes; uniform vec2 uMouse; uniform float uScroll; // --- simplex-ish value noise + fbm --- vec2 hash(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return -1.0 + 2.0 * fract(sin(p) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix( mix(dot(hash(i), f), dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x), mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y); } float fbm(vec2 p) { float v = 0.0, a = 0.55; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.05 + vec2(11.3, 7.7); a *= 0.5; } return v; } void main() { vec2 uv = gl_FragCoord.xy / uRes; vec2 p = uv; p.x *= uRes.x / uRes.y; float t = uTime * 0.05; // mouse drift vec2 m = (uMouse - 0.5) * 0.35; // domain-warped fbm: slow molten flow vec2 q = vec2(fbm(p * 1.4 + t + m), fbm(p * 1.4 - t * 0.7)); vec2 r = vec2(fbm(p * 1.8 + q * 1.6 + vec2(1.7, 9.2) + t * 0.6), fbm(p * 1.8 + q * 1.6 + vec2(8.3, 2.8) - t * 0.4)); float f = fbm(p * 2.0 + r * 1.8); // ridges -> thin ember veins float vein = smoothstep(0.42, 0.5, f) * smoothstep(0.62, 0.5, f); // palette: warm black -> deep rust -> ember -> pale gold vec3 ink = vec3(0.047, 0.043, 0.035); vec3 rust = vec3(0.30, 0.10, 0.04); vec3 ember = vec3(1.0, 0.30, 0.14); vec3 gold = vec3(1.0, 0.78, 0.5); vec3 col = ink; col = mix(col, rust, smoothstep(0.15, 0.75, f) * 0.85); col = mix(col, ember, vein * 0.9); col += gold * pow(vein, 3.0) * 0.6; // glow pool near the mouse float md = distance(uv, uMouse); col += ember * 0.10 * smoothstep(0.45, 0.0, md) * (0.5 + 0.5 * f); // top fade + vignette so type stays readable col *= smoothstep(-0.15, 0.55, uv.y) * 0.9 + 0.1; float vig = smoothstep(1.25, 0.45, length(uv - vec2(0.5, 0.42))); col *= mix(0.55, 1.0, vig); // scroll dims the field as the hero leaves col *= 1.0 - uScroll * 0.85; gl_FragColor = vec4(col, 1.0); } `, }); scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material)); const hero = document.querySelector("[data-hero]"); const mouseTarget = new THREE.Vector2(0.5, 0.5); function resize() { const w = hero.clientWidth, h = hero.clientHeight; renderer.setSize(w, h, false); renderer.getDrawingBufferSize(uniforms.uRes.value); } resize(); window.addEventListener("resize", resize); if (isFinePointer) { window.addEventListener("pointermove", (e) => { mouseTarget.set(e.clientX / innerWidth, 1.0 - e.clientY / innerHeight); }); } // dim shader as hero scrolls away + pause when offscreen let heroVisible = true; new IntersectionObserver(([entry]) => (heroVisible = entry.isIntersecting), { threshold: 0 }).observe(hero); const clock = new THREE.Clock(); gsap.ticker.add(() => { if (!heroVisible) return; uniforms.uTime.value = clock.getElapsedTime(); uniforms.uMouse.value.lerp(mouseTarget, 0.05); uniforms.uScroll.value = Math.min(window.scrollY / Math.max(hero.clientHeight, 1), 1); renderer.render(scene, camera); }); } /* ------------------------------------------------------------ Text splitting (words -> measured lines), no club plugins ------------------------------------------------------------ */ function splitToLines(el) { const text = el.textContent.trim().replace(/\s+/g, " "); const hasEm = el.querySelector("em"); // preserve styling by re-walking original nodes const frag = []; el.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { node.textContent.split(/\s+/).filter(Boolean).forEach((w) => frag.push({ w, em: false })); } else if (node.nodeName === "BR") { frag.push({ br: true }); } else { node.textContent.split(/\s+/).filter(Boolean).forEach((w) => frag.push({ w, em: node.nodeName === "EM" })); } }); el.innerHTML = ""; const words = frag.map((item) => { if (item.br) { const br = document.createElement("br"); el.appendChild(br); return null; } const span = document.createElement("span"); span.style.display = "inline-block"; if (item.em) { const em = document.createElement("em"); em.textContent = item.w; span.appendChild(em); } else span.textContent = item.w; el.appendChild(span); el.appendChild(document.createTextNode(" ")); return span; }).filter(Boolean); // group by rendered line const lines = []; let lastTop = null; words.forEach((w) => { const top = w.offsetTop; if (top !== lastTop) { lines.push([]); lastTop = top; } lines[lines.length - 1].push(w); }); el.innerHTML = ""; const lineSpans = lines.map((lineWords) => { const outer = document.createElement("span"); outer.className = "split-line"; const inner = document.createElement("span"); inner.innerHTML = lineWords.map((w) => w.outerHTML).join(" "); outer.appendChild(inner); el.appendChild(outer); return inner; }); el.classList.add("is-split"); return lineSpans; } function splitAll() { document.querySelectorAll("[data-reveal-lines]").forEach((el) => { el._lines = splitToLines(el); }); document.querySelectorAll("[data-reveal-words]").forEach((el) => { el.classList.add("is-split"); // simple fade targets }); } /* ------------------------------------------------------------ Preloader ------------------------------------------------------------ */ function initPreloader() { const pre = document.querySelector(".preloader"); if (!pre) return; const countEl = pre.querySelector("[data-preloader-count]"); const barEl = pre.querySelector("[data-preloader-bar]"); const wordEl = pre.querySelector("[data-preloader-word]"); const words = ["interfaces", "systems", "motion", "obsession"]; document.body.style.overflow = "hidden"; const state = { n: 0 }; const tl = gsap.timeline({ onComplete: () => { document.body.style.overflow = ""; pre.remove(); window._heroIntro && window._heroIntro.play(); ScrollTrigger.refresh(); }, }); tl.to(state, { n: 100, duration: prefersReduced ? 0.1 : 1.9, ease: "power3.inOut", onUpdate: () => { const v = Math.round(state.n); countEl.textContent = v; barEl.style.width = v + "%"; wordEl.textContent = words[Math.min(Math.floor(v / 26), words.length - 1)]; }, }); tl.to(pre, { clipPath: "inset(0 0 100% 0)", duration: prefersReduced ? 0.1 : 0.9, ease: "expo.inOut", }, "+=0.15"); } /* ------------------------------------------------------------ Hero intro (played by preloader) ------------------------------------------------------------ */ function initHeroIntro() { const lines = gsap.utils.toArray("[data-hero-line]"); const fades = gsap.utils.toArray("[data-hero-fade]"); const kicker = document.querySelector(".hero__kicker"); gsap.set(lines, { yPercent: 110 }); if (kicker) gsap.set(kicker, { opacity: 0, y: 14 }); const tl = gsap.timeline({ paused: true }); tl.to(lines, { yPercent: 0, duration: prefersReduced ? 0.1 : 1.3, ease: "expo.out", stagger: 0.12, }); tl.to(kicker, { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, "-=0.8"); tl.to(fades, { opacity: 1, duration: 0.9, ease: "power2.out", stagger: 0.07 }, "-=0.6"); window._heroIntro = tl; } /* ------------------------------------------------------------ Scroll reveals ------------------------------------------------------------ */ function initScrollReveals() { // line-by-line masked rise document.querySelectorAll("[data-reveal-lines]").forEach((el) => { if (!el._lines) return; gsap.set(el._lines, { yPercent: 115 }); gsap.to(el._lines, { yPercent: 0, duration: 1.1, ease: "expo.out", stagger: 0.09, scrollTrigger: { trigger: el, start: "top 85%" }, }); }); // label fades document.querySelectorAll("[data-reveal-words]").forEach((el) => { if (el.closest(".hero")) return; // hero handles its own gsap.from(el, { opacity: 0, y: 16, duration: 0.8, ease: "power2.out", scrollTrigger: { trigger: el, start: "top 88%" }, }); }); // work rows slide in gsap.utils.toArray("[data-work-item]").forEach((item, i) => { gsap.from(item.querySelector(".work__row"), { opacity: 0, y: 40, duration: 0.9, ease: "power3.out", scrollTrigger: { trigger: item, start: "top 90%" }, }); }); // services gsap.utils.toArray("[data-service]").forEach((item) => { gsap.from(item, { opacity: 0, y: 36, duration: 0.8, ease: "power3.out", scrollTrigger: { trigger: item, start: "top 90%" }, }); }); // portrait parallax const portrait = document.querySelector("[data-parallax]"); if (portrait && !prefersReduced) { gsap.fromTo(portrait, { y: 40 }, { y: -40, ease: "none", scrollTrigger: { trigger: portrait, start: "top bottom", end: "bottom top", scrub: 1 }, }); } // giant CTA lines gsap.utils.toArray("[data-cta-line]").forEach((line, i) => { gsap.from(line, { yPercent: 60, opacity: 0, duration: 1.2, ease: "expo.out", delay: i * 0.08, scrollTrigger: { trigger: ".contact__cta", start: "top 85%" }, }); }); } /* ------------------------------------------------------------ Work list: floating thumbnail follows cursor (desktop only) ------------------------------------------------------------ */ function initWorkList() { if (!isFinePointer) return; const items = document.querySelectorAll("[data-work-item]"); items.forEach((item) => { const thumb = item.querySelector("[data-work-thumb]"); if (!thumb) return; const xTo = gsap.quickTo(thumb, "x", { duration: 0.5, ease: "power3.out" }); const yTo = gsap.quickTo(thumb, "y", { duration: 0.5, ease: "power3.out" }); // thumbs are position:fixed only on wide viewports; skip when static const floating = () => getComputedStyle(thumb).position === "fixed"; item.addEventListener("pointerenter", (e) => { if (!floating()) return; gsap.set(thumb, { x: e.clientX, y: e.clientY }); gsap.to(thumb, { opacity: 1, scale: 1, rotate: 0, duration: 0.45, ease: "power3.out", transformOrigin: "center", }); }); item.addEventListener("pointermove", (e) => { xTo(e.clientX); yTo(e.clientY); }); item.addEventListener("pointerleave", () => { gsap.to(thumb, { opacity: 0, scale: 0.85, rotate: -4, duration: 0.35, ease: "power2.in" }); }); }); } /* ------------------------------------------------------------ Marquee: infinite loop + scroll-velocity skew ------------------------------------------------------------ */ function initMarquee() { const track = document.querySelector("[data-marquee-track]"); if (!track) return; track.innerHTML += track.innerHTML; // duplicate for seamless wrap if (prefersReduced) return; const half = track.scrollWidth / 2; const loop = gsap.to(track, { x: -half, duration: 28, ease: "none", repeat: -1, modifiers: { x: (x) => (parseFloat(x) % half) + "px" }, }); // speed up with scroll velocity ScrollTrigger.create({ trigger: document.body, start: 0, end: "max", onUpdate: (self) => { const v = gsap.utils.clamp(0.5, 4, Math.abs(self.getVelocity()) / 280); gsap.to(loop, { timeScale: v, duration: 0.4, overwrite: true }); }, }); } /* ------------------------------------------------------------ Stat counters ------------------------------------------------------------ */ function initCounters() { document.querySelectorAll("[data-count]").forEach((el) => { const target = +el.dataset.count; const state = { n: 0 }; ScrollTrigger.create({ trigger: el, start: "top 88%", once: true, onEnter: () => gsap.to(state, { n: target, duration: 1.6, ease: "power3.out", onUpdate: () => (el.textContent = Math.round(state.n)), }), }); }); } /* ------------------------------------------------------------ Custom cursor ------------------------------------------------------------ */ function initCursor() { if (!isFinePointer) return; const root = document.querySelector(".cursor"); const dot = root.querySelector("[data-cursor-dot]"); const ring = root.querySelector("[data-cursor-ring]"); const label = root.querySelector("[data-cursor-label]"); document.body.classList.add("has-cursor"); const dotX = gsap.quickTo(dot, "x", { duration: 0.12, ease: "power2.out" }); const dotY = gsap.quickTo(dot, "y", { duration: 0.12, ease: "power2.out" }); const ringX = gsap.quickTo(ring, "x", { duration: 0.45, ease: "power3.out" }); const ringY = gsap.quickTo(ring, "y", { duration: 0.45, ease: "power3.out" }); window.addEventListener("pointermove", (e) => { dotX(e.clientX); dotY(e.clientY); ringX(e.clientX); ringY(e.clientY); }); document.querySelectorAll("[data-cursor], a, button").forEach((el) => { el.addEventListener("pointerenter", () => { const text = el.dataset.cursor; if (text) { label.textContent = text; root.classList.add("is-hover"); } }); el.addEventListener("pointerleave", () => root.classList.remove("is-hover")); }); } /* ------------------------------------------------------------ Magnetic links ------------------------------------------------------------ */ function initMagnetic() { if (!isFinePointer || prefersReduced) return; document.querySelectorAll("[data-magnetic]").forEach((el) => { const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3.out" }); const yTo = gsap.quickTo(el, "y", { duration: 0.4, ease: "power3.out" }); el.addEventListener("pointermove", (e) => { const r = el.getBoundingClientRect(); xTo((e.clientX - (r.left + r.width / 2)) * 0.35); yTo((e.clientY - (r.top + r.height / 2)) * 0.35); }); el.addEventListener("pointerleave", () => { xTo(0); yTo(0); }); }); }