GRV-Summit-Site/components/motion/ScrollReveal.tsx
“kirukib” 3693495dd0
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
Add site-wide topography patterns and refine section styling.
Use mainwhite.svg on white sections with curvy green transitions into flat green bands, improve text and button contrast, and deploy via OpenNext on Cloudflare Workers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 20:34:36 +03:00

104 lines
2.5 KiB
TypeScript

"use client";
import {
useEffect,
useRef,
useState,
type ElementType,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export type ScrollRevealVariant = "hero" | "card" | "fade-up" | "section";
type Props = {
children: ReactNode;
className?: string;
variant?: ScrollRevealVariant;
/** Stagger delay after element enters view (ms) */
delay?: number;
/** Animate on mount (for above-the-fold hero content) */
immediate?: boolean;
as?: ElementType;
};
const hidden: Record<ScrollRevealVariant, string> = {
hero: "opacity-0 translate-y-10 scale-[0.98]",
card: "opacity-0 translate-y-7",
"fade-up": "opacity-0 translate-y-8",
section: "opacity-0 translate-y-14",
};
const shown: Record<ScrollRevealVariant, string> = {
hero: "opacity-100 translate-y-0 scale-100",
card: "opacity-100 translate-y-0",
"fade-up": "opacity-100 translate-y-0",
section: "opacity-100 translate-y-0",
};
const duration: Record<ScrollRevealVariant, string> = {
hero: "duration-[900ms]",
card: "duration-[650ms]",
"fade-up": "duration-700",
section: "duration-[800ms]",
};
export function ScrollReveal({
children,
className,
variant = "fade-up",
delay = 0,
immediate = false,
as: Tag = "div",
}: Props) {
const ref = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (immediate) {
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}
const el = ref.current;
if (!el) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
if (mq.matches) {
setVisible(true);
return;
}
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
obs.disconnect();
}
},
{ threshold: 0.1, rootMargin: "0px 0px -6% 0px" }
);
obs.observe(el);
return () => obs.disconnect();
}, [immediate]);
return (
<Tag
ref={ref}
className={cn(
"ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[opacity,transform]",
duration[variant],
"motion-reduce:!translate-y-0 motion-reduce:!scale-100 motion-reduce:!opacity-100 motion-reduce:!transition-none",
visible ? shown[variant] : hidden[variant],
visible ? "transition-[opacity,transform]" : "",
variant === "card" && "topo-card-layer relative z-20 isolate",
className
)}
style={{ transitionDelay: visible ? `${delay}ms` : "0ms" }}
>
{children}
</Tag>
);
}