diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..49cec91 --- /dev/null +++ b/js/main.js @@ -0,0 +1,544 @@ +/* ============================================================ + 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); }); + }); +}