// 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 (
{l.ch}
);
})}
{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 && {cursorChar}}
);
}
window.Typewriter = Typewriter;