Files
waggle-os/apps/www/app/_components/Reveal.tsx
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

87 lines
2.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
interface RevealProps {
readonly children: ReactNode;
/** Stagger slot 15 → transition-delay 60ms steps (see globals.css). */
readonly delay?: 1 | 2 | 3 | 4 | 5;
readonly as?: 'div' | 'section' | 'li' | 'span';
readonly className?: string;
readonly style?: CSSProperties;
}
/**
* Scroll-reveal wrapper: fades + lifts children in when they enter the
* viewport. Purely presentational — content is in the DOM at SSR (SEO-safe)
* and `prefers-reduced-motion` disables the effect entirely via globals.css.
*/
export default function Reveal({
children,
delay,
as: Tag = 'div',
className,
style,
}: RevealProps) {
const nodeRef = useRef<HTMLElement | null>(null);
const [visible, setVisible] = useState(false);
const setNode = useCallback((node: HTMLElement | null) => {
nodeRef.current = node;
}, []);
useEffect(() => {
const node = nodeRef.current;
if (!node) return;
if (typeof IntersectionObserver === 'undefined') {
const fallback = window.setTimeout(() => setVisible(true), 0);
return () => window.clearTimeout(fallback);
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
}
},
{ rootMargin: '0px 0px -10% 0px', threshold: 0.1 },
);
observer.observe(node);
// Safety net: if a section is never scrolled into view — a crawler, a
// social-preview renderer, or a full-page screenshot that paints without
// scrolling — the observer never fires and the content would stay stuck at
// opacity:0. Reveal it anyway shortly after mount so no section is ever a
// headline floating in an empty void. Real users scrolling normally still
// trip the observer first and get the entrance animation per section.
const fallback = window.setTimeout(() => setVisible(true), 900);
return () => {
observer.disconnect();
window.clearTimeout(fallback);
};
}, []);
const classes = [
'reveal',
visible ? 'is-visible' : '',
delay ? `reveal-d${delay}` : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<Tag ref={setNode} className={classes} style={style}>
{children}
</Tag>
);
}