// scroll.jsx — Scroll-driven animation utilities
// Includes: useScrollProgress, useElementProgress, Parallax, CountUp,
// WordReveal, LineReveal, StickyHeading, ScrollProgressBar, ScaleOnScroll
/* ───────── Hooks ───────── */
// 0..1 page scroll progress
function useScrollProgress() {
const [p, setP] = React.useState(0);
React.useEffect(() => {
const calc = () => {
const h = document.documentElement;
const max = (h.scrollHeight - h.clientHeight) || 1;
setP(Math.max(0, Math.min(1, window.scrollY / max)));
};
calc();
window.addEventListener('scroll', calc, { passive: true });
window.addEventListener('resize', calc);
return () => {
window.removeEventListener('scroll', calc);
window.removeEventListener('resize', calc);
};
}, []);
return p;
}
// Per-element scroll progress: 0 when bottom of element enters viewport bottom,
// 1 when top of element exits viewport top. Smooth across the whole transition.
function useElementProgress(ref) {
const [p, setP] = React.useState(0);
React.useEffect(() => {
if (!ref.current) return;
const el = ref.current;
const calc = () => {
const r = el.getBoundingClientRect();
const vh = window.innerHeight || 1;
const total = r.height + vh;
const passed = vh - r.top;
setP(Math.max(0, Math.min(1, passed / total)));
};
calc();
window.addEventListener('scroll', calc, { passive: true });
window.addEventListener('resize', calc);
return () => {
window.removeEventListener('scroll', calc);
window.removeEventListener('resize', calc);
};
}, [ref]);
return p;
}
/* ───────── Parallax ───────── */
function Parallax({ speed = 0.3, children, as: As = 'div', style, ...rest }) {
const ref = React.useRef(null);
const [y, setY] = React.useState(0);
React.useEffect(() => {
if (!ref.current) return;
const el = ref.current;
const onScroll = () => {
const r = el.getBoundingClientRect();
const vh = window.innerHeight || 1;
// Distance from element center to viewport center
const center = r.top + r.height / 2 - vh / 2;
setY(-center * speed);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, [speed]);
return (
{children}
);
}
/* ───────── CountUp ───────── */
// Counts up from 0 to `to` once the element enters viewport. `duration` in ms.
function CountUp({ to, duration = 1400, format = (n) => Math.round(n), prefix = '', suffix = '' }) {
const ref = React.useRef(null);
const [val, setVal] = React.useState(0);
const [seen, setSeen] = React.useState(false);
React.useEffect(() => {
if (!ref.current || seen) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
setSeen(true);
const start = performance.now();
const tick = (t) => {
const p = Math.min(1, (t - start) / duration);
// ease-out cubic
const eased = 1 - Math.pow(1 - p, 3);
setVal(to * eased);
if (p < 1) requestAnimationFrame(tick);
else setVal(to);
};
requestAnimationFrame(tick);
io.disconnect();
}
});
}, { threshold: 0.4 });
io.observe(ref.current);
return () => io.disconnect();
}, [to, duration, seen]);
return {prefix}{format(val)}{suffix};
}
/* ───────── Word-by-word text reveal ───────── */
function WordReveal({ text, as: As = 'span', delay = 0, stagger = 40, className = '' }) {
const ref = React.useRef(null);
const [seen, setSeen] = React.useState(false);
React.useEffect(() => {
if (!ref.current) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
setTimeout(() => setSeen(true), delay);
io.disconnect();
}
});
}, { threshold: 0.2, rootMargin: '0px 0px -8% 0px' });
io.observe(ref.current);
return () => io.disconnect();
}, [delay]);
const words = text.split(' ');
return (
{words.map((w, i) => (
{w}
{i < words.length - 1 && '\u00A0'}
))}
);
}
/* ───────── Line reveal — for long paragraphs ───────── */
function LineReveal({ children, delay = 0, className = '' }) {
const ref = React.useRef(null);
const [seen, setSeen] = React.useState(false);
React.useEffect(() => {
if (!ref.current) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
setTimeout(() => setSeen(true), delay);
io.disconnect();
}
});
}, { threshold: 0.2, rootMargin: '0px 0px -5% 0px' });
io.observe(ref.current);
return () => io.disconnect();
}, [delay]);
return (
);
}
/* ───────── ScrollProgressBar (top of viewport) ───────── */
function ScrollProgressBar() {
const p = useScrollProgress();
return (
);
}
/* ───────── ScaleOnScroll — element scales/fades based on its own progress ───────── */
function ScaleOnScroll({ children, from = 0.92, to = 1, style, as: As = 'div', ...rest }) {
const ref = React.useRef(null);
const p = useElementProgress(ref);
// Scale up to peak at 50% progress then back down
const t = 1 - Math.abs(p - 0.5) * 2;
const scale = from + (to - from) * Math.max(0, t);
return (
{children}
);
}
/* ───────── BgShift — color shifts based on element progress ───────── */
function MarqueeRibbon({ items, accent, speed = 18 }) {
// Reusable marquee ribbon; for adding kinetic separators between sections
const doubled = [...items, ...items];
return (
{doubled.map((it, i) => (
{it}
))}
);
}
/* ───────── Typing-style scroll reveal ─────────
Splits text children into per-character spans and reveals them in sequence
when the host enters the viewport. Handles nested React elements
(e.g. …) so styling is preserved per character. */
function TypeReveal({ children, speed = 32, delay = 0, caret = true, className = '', as: As = 'span' }) {
const ref = React.useRef(null);
const [seen, setSeen] = React.useState(false);
const [done, setDone] = React.useState(false);
// Process children -> array of char spans with running index, total count
const total = React.useMemo(() => {
let n = 0;
const walk = (node) => {
if (node == null || typeof node === 'boolean') return;
if (typeof node === 'string' || typeof node === 'number') {
const s = String(node);
for (const ch of s) { if (ch !== '\n') n++; }
return;
}
if (Array.isArray(node)) { node.forEach(walk); return; }
if (React.isValidElement(node)) { walk(node.props.children); return; }
};
walk(children);
return n;
}, [children]);
const rendered = React.useMemo(() => {
let idx = 0;
const render = (node, key = 'r') => {
if (node == null || typeof node === 'boolean') return null;
if (typeof node === 'string' || typeof node === 'number') {
const s = String(node);
const chars = [];
for (const ch of s) {
const i = idx++;
chars.push(
{ch === ' ' ? '\u00A0' : ch}
);
}
return chars;
}
if (Array.isArray(node)) return node.map((n, i) => render(n, `${key}-a${i}`));
if (React.isValidElement(node)) {
const inner = render(node.props.children, `${key}-e`);
return React.cloneElement(node, { key: `${key}-el`, ...node.props }, inner);
}
return node;
};
return render(children);
}, [children, seen, delay, speed]);
React.useEffect(() => {
if (!ref.current) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { setSeen(true); io.disconnect(); }
});
}, { threshold: 0.18, rootMargin: '0px 0px -6% 0px' });
io.observe(ref.current);
return () => io.disconnect();
}, []);
React.useEffect(() => {
if (!seen) return;
const t = setTimeout(() => setDone(true), delay + total * speed + 400);
return () => clearTimeout(t);
}, [seen, delay, total, speed]);
return (
{rendered}
{caret && }
);
}
Object.assign(window, { TypeReveal });
Object.assign(window, {
useScrollProgress, useElementProgress,
Parallax, CountUp, WordReveal, LineReveal,
ScrollProgressBar, ScaleOnScroll, MarqueeRibbon,
});