// Letter-by-letter animated heading (ported from the framer-motion // AnimatedText component to this project's React/Babel + GSAP setup). // No underline floor line. Animates when scrolled into view. // // Usage: // // function AnimatedText({ text, parts, className = '', accentFrom = null, stack = false, duration = 0.5, delay = 0.05, repel = false, repelRadius = 110, repelStrength = 14 }) { const ref = React.useRef(null); // Flatten to a list of letters carrying { ch, accent }. const letters = []; let label = ''; if (parts && parts.length) { parts.forEach((p) => { label += p.t; Array.from(p.t).forEach((ch) => letters.push({ ch, accent: !!p.accent })); }); } else { label = text || ''; Array.from(label).forEach((ch, i) => letters.push({ ch, accent: accentFrom != null && i >= accentFrom })); } // Group letters into words (split on spaces) so words wrap as units. const words = []; let cur = []; letters.forEach((l) => { if (l.ch === ' ') { words.push(cur); cur = []; } else cur.push(l); }); words.push(cur); React.useEffect(() => { if (!ref.current || !window.gsap) return; const spans = ref.current.querySelectorAll('.at-letter'); window.gsap.set(spans, { yPercent: 110, opacity: 0 }); const play = () => { window.gsap.to(spans, { yPercent: 0, opacity: 1, ease: 'back.out(1.7)', duration, stagger: delay, delay: 0.1, onComplete: () => { ref.current && ref.current.querySelectorAll('.at-letter-wrap').forEach((w) => { w.style.overflow = 'visible'; }); }, }); }; const io = new IntersectionObserver((entries, obs) => { entries.forEach((e) => { if (e.isIntersecting) { play(); obs.disconnect(); } }); }, { threshold: 0.25 }); io.observe(ref.current); return () => io.disconnect(); }, []); // Cursor repel: each letter springs away from the pointer, eases back to rest. React.useEffect(() => { if (!repel || !ref.current || !window.gsap) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; // Touch devices have no real cursor, so "pointermove" instead fires // during scroll/touch-drag, scattering the letters as you scroll. if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; const el = ref.current; const letters = Array.from(el.querySelectorAll('.at-letter')); if (!letters.length) return; const state = letters.map((letter) => ({ letter, ox: 0, oy: 0, xTo: window.gsap.quickTo(letter, 'x', { duration: 0.55, ease: 'power3.out' }), yTo: window.gsap.quickTo(letter, 'y', { duration: 0.55, ease: 'power3.out' }), })); const measure = () => { const cr = el.getBoundingClientRect(); state.forEach((s) => { const lr = s.letter.getBoundingClientRect(); s.ox = lr.left - cr.left + lr.width / 2; s.oy = lr.top - cr.top + lr.height / 2; }); }; requestAnimationFrame(() => requestAnimationFrame(measure)); // The entrance drop-in (see the effect above: each letter animates in // via yPercent over `duration` + a per-letter `stagger` delay, up to // ~1.2s for a 13-letter heading) is still playing 2 frames after // mount, so that early measure() captures mid-animation positions — // every distance came out wrong for the rest of the page's life, // since nothing else ever re-measured until the cursor happened to // leave and re-enter. Re-measure once more after the entrance has // certainly settled. const settleTimer = setTimeout(measure, 1400); el.addEventListener('pointerenter', measure); const onMove = (e) => { const cr = el.getBoundingClientRect(); const mx = e.clientX - cr.left, my = e.clientY - cr.top; state.forEach((s) => { const dx = s.ox - mx, dy = s.oy - my; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < repelRadius && dist > 0) { const force = Math.pow(1 - dist / repelRadius, 2) * repelStrength; const angle = Math.atan2(dy, dx); s.xTo(Math.cos(angle) * force); s.yTo(Math.sin(angle) * force); } else { s.xTo(0); s.yTo(0); } }); }; const onLeave = () => state.forEach((s) => { s.xTo(0); s.yTo(0); }); el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); window.addEventListener('resize', measure); return () => { clearTimeout(settleTimer); el.removeEventListener('pointerenter', measure); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); window.removeEventListener('resize', measure); }; }, [repel, repelRadius, repelStrength]); let li = 0; return ( {label} {words.map((word, wi) => ( {word.map((l) => { const key = li++; return ( ); })} {wi < words.length - 1 && (stack ? : )} ))} ); } window.AnimatedText = AnimatedText; // Typewriter: cycles through a list of words, typing then deleting each. function Typewriter({ words, speed = 70, delayBetweenWords = 1600, className = '', cursor = true, cursorChar = '|' }) { const [displayText, setDisplayText] = React.useState(''); const [isDeleting, setIsDeleting] = React.useState(false); const [wordIndex, setWordIndex] = React.useState(0); const [charIndex, setCharIndex] = React.useState(0); const [showCursor, setShowCursor] = React.useState(true); const currentWord = words[wordIndex]; React.useEffect(() => { const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduced) { setDisplayText(currentWord); return; } const timeout = setTimeout(() => { if (!isDeleting) { if (charIndex < currentWord.length) { setDisplayText(currentWord.substring(0, charIndex + 1)); setCharIndex(charIndex + 1); } else { setTimeout(() => setIsDeleting(true), delayBetweenWords); } } else { if (charIndex > 0) { setDisplayText(currentWord.substring(0, charIndex - 1)); setCharIndex(charIndex - 1); } else { setIsDeleting(false); setWordIndex((prev) => (prev + 1) % words.length); } } }, isDeleting ? speed / 2 : speed); return () => clearTimeout(timeout); }, [charIndex, currentWord, isDeleting, speed, delayBetweenWords, wordIndex, words]); React.useEffect(() => { if (!cursor) return; const iv = setInterval(() => setShowCursor((p) => !p), 500); return () => clearInterval(iv); }, [cursor]); return ( {displayText} {cursor && } ); } window.Typewriter = Typewriter;