This commit is contained in:
86
apps/www/app/_components/Reveal.tsx
Normal file
86
apps/www/app/_components/Reveal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface RevealProps {
|
||||
readonly children: ReactNode;
|
||||
/** Stagger slot 1–5 → 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user