Files
waggle-os/apps/www/app/_components/Reveal.tsx
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

87 lines
2.3 KiB
TypeScript
Raw 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') {
setVisible(true);
return;
}
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>
);
}