ultisuite-client/components/landing/landing-reveal.tsx
R3D347HR4Y d6d18f911b
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Lots of stuff and mobile app
2026-06-17 00:13:28 +02:00

78 lines
1.8 KiB
TypeScript

"use client"
import {
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react"
import { cn } from "@/lib/utils"
interface LandingRevealProps {
children: ReactNode
className?: string
/** Délai (s) appliqué à la transition — pour le stagger. */
delay?: number
as?: "div" | "section" | "li" | "span"
}
function isInRevealViewport(node: HTMLElement, margin = 36): boolean {
const rect = node.getBoundingClientRect()
return rect.top < window.innerHeight - margin && rect.bottom > margin
}
/** Révèle son contenu à l'entrée dans le viewport (une seule fois). */
export function LandingReveal({
children,
className,
delay = 0,
as: Tag = "div",
}: LandingRevealProps) {
const ref = useRef<HTMLElement | null>(null)
const [mounted, setMounted] = useState(false)
const [revealed, setRevealed] = useState(true)
useLayoutEffect(() => {
setMounted(true)
const node = ref.current
if (!node) return
if (typeof IntersectionObserver === "undefined") {
setRevealed(true)
return
}
if (isInRevealViewport(node)) {
setRevealed(true)
return
}
setRevealed(false)
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setRevealed(true)
observer.disconnect()
}
},
{ threshold: 0.12, rootMargin: "0px 0px -36px 0px" }
)
observer.observe(node)
return () => observer.disconnect()
}, [])
return (
<Tag
ref={ref as never}
className={cn("landing-reveal", className)}
data-revealed={
mounted ? (revealed ? "true" : "false") : undefined
}
suppressHydrationWarning
style={delay ? ({ "--reveal-delay": `${delay}s` } as React.CSSProperties) : undefined}
>
{children}
</Tag>
)
}