// Big "ARNEL" wordmark that drops a trail of Life Off Screen photos as the // cursor moves across it (SVG text used as a mask over the dropped photos). // // How it works: the same word is drawn twice — once as the neon-green fill // you see at rest, and once (in white) inside an SVG . Photos are // appended into a masked , so they can only ever paint inside the // letterforms. Every time the pointer travels far enough, the next photo in // the set is dropped at the cursor, pops in, lingers, then fades out. // // Drops are created imperatively (not via React state) so a fast-moving // cursor never triggers a re-render storm. function NameReveal() { const svgRef = React.useRef(null); const layerRef = React.useRef(null); const [touched, setTouched] = React.useState(false); // Photos come straight from the Life Off Screen data. const shots = (window.SITE_DATA.lifestyle || []).map((l) => l.img); React.useEffect(() => { const svg = svgRef.current; const layer = layerRef.current; const defs = svg && svg.querySelector('defs'); if (!svg || !layer || !defs || !shots.length) return; const SVG_NS = 'http://www.w3.org/2000/svg'; const VB_W = 1200; // viewBox width (must match the below) const VB_H = 260; // viewBox height const STEP = 70; // viewBox units the cursor must travel per drop const LIFE = 1100; // ms a photo stays fully visible const MAX = 16; // hard cap on live photos const CORNER = 18; // rounded-corner radius, in viewBox px const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; let lastX = null; let lastY = null; let idx = 0; let clipSeq = 0; const live = []; // Each dropped photo gets its OWN clipPath (userSpaceOnUse, matching // its exact x/y/w/h) rather than one shared objectBoundingBox // clipPath — Chromium ignores rx/ry on rects inside // objectBoundingBox clipPaths, so a shared one clips the rectangle // but silently drops the rounding. const removeWithClip = (img) => { img.remove(); if (img.__clip) img.__clip.remove(); }; const drop = (x, y) => { const w = 300; const h = 230; const left = x - w / 2; const top = y - h / 2; const clipId = 'nr-clip-' + (clipSeq++); const clip = document.createElementNS(SVG_NS, 'clipPath'); clip.id = clipId; const rect = document.createElementNS(SVG_NS, 'rect'); rect.setAttribute('x', left); rect.setAttribute('y', top); rect.setAttribute('width', w); rect.setAttribute('height', h); rect.setAttribute('rx', CORNER); rect.setAttribute('ry', CORNER); clip.appendChild(rect); defs.appendChild(clip); const img = document.createElementNS(SVG_NS, 'image'); img.setAttribute('href', shots[idx % shots.length]); img.setAttribute('x', left); img.setAttribute('y', top); img.setAttribute('width', w); img.setAttribute('height', h); img.setAttribute('preserveAspectRatio', 'xMidYMid slice'); img.setAttribute('class', 'nr-drop'); img.setAttribute('clip-path', 'url(#' + clipId + ')'); img.__clip = clip; // Alternate the tilt so the trail feels hand-tossed, not mechanical. img.style.setProperty('--rot', (idx % 2 ? 1 : -1) * (3 + (idx % 4) * 1.6) + 'deg'); idx++; layer.appendChild(img); live.push(img); // Pop in on the next frame so the transition actually runs. requestAnimationFrame(() => img.classList.add('is-in')); // Retire it: fade out, then remove from the DOM. const t = setTimeout(() => { img.classList.remove('is-in'); img.classList.add('is-out'); setTimeout(() => { removeWithClip(img); const i = live.indexOf(img); if (i > -1) live.splice(i, 1); }, 500); }, reduced ? 400 : LIFE); img.__timer = t; // Trim the oldest if the cursor is racing around. while (live.length > MAX) { const old = live.shift(); clearTimeout(old.__timer); removeWithClip(old); } }; const onMove = (e) => { const r = svg.getBoundingClientRect(); if (!r.width) return; const x = ((e.clientX - r.left) / r.width) * VB_W; const y = ((e.clientY - r.top) / r.height) * VB_H; if (lastX === null) { lastX = x; lastY = y; drop(x, y); setTouched(true); return; } const dx = x - lastX; const dy = y - lastY; if (Math.sqrt(dx * dx + dy * dy) < STEP) return; // not far enough yet lastX = x; lastY = y; drop(x, y); setTouched(true); }; const onLeave = () => { lastX = null; lastY = null; }; svg.addEventListener('pointermove', onMove); svg.addEventListener('pointerleave', onLeave); // Touch devices have no cursor to move, so the trail plays itself while // the wordmark is on screen (a drag across it still drops photos too). let autoTimer = null; let io = null; const isTouch = !window.matchMedia('(hover: hover)').matches; if (isTouch) { let t = 0; const tick = () => { t += 1; const p = (t % 24) / 24; // sweep left -> right const x = 70 + (VB_W - 140) * p; const y = VB_H * (0.5 + 0.2 * Math.sin(t / 2.1)); // gentle wave drop(x, y); setTouched(true); }; io = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !autoTimer) { tick(); autoTimer = setInterval(tick, 340); } else if (!e.isIntersecting && autoTimer) { clearInterval(autoTimer); autoTimer = null; } }); }, { threshold: 0.3 }); io.observe(svg); } return () => { svg.removeEventListener('pointermove', onMove); svg.removeEventListener('pointerleave', onLeave); if (autoTimer) clearInterval(autoTimer); if (io) io.disconnect(); live.forEach((el) => { clearTimeout(el.__timer); removeWithClip(el); }); }; }, [shots.length]); return (
/ THE PERSON BEHIND THE WORK
{/* black hides, white reveals */} ARNEL {/* Dropped-photo clipPaths (one per live photo) are injected here at runtime — see drop() below. Plain CSS border-radius doesn't reliably round SVG corners, and a single shared objectBoundingBox clipPath doesn't work either: Chromium ignores rx/ry on rects inside objectBoundingBox clipPaths (the clip applies, the rounding silently doesn't). A per-photo userSpaceOnUse clipPath with absolute pixel rx/ry sidesteps that bug and is universally supported. */} {/* Resting state: the neon-green wordmark */} ARNEL {/* Dropped photos land here, clipped to the letterforms */} move your cursor
); } window.NameReveal = NameReveal;