"use client"; import { useState, useEffect, useRef, type CSSProperties } from "react"; export function useStaggerReveal(count: number, baseDelay = 80) { const [visible, setVisible] = useState>(new Set()); useEffect(() => { const timers: ReturnType[] = []; for (let i = 0; i < count; i++) { timers.push( setTimeout( () => setVisible((prev) => new Set(prev).add(i)), baseDelay * (i + 1) + 200 ) ); } return () => timers.forEach(clearTimeout); }, [count, baseDelay]); return visible; } /** * Tracks the rendered width of an element. `mounted` stays false until the * first measurement lands, so callers can avoid rendering a width-dependent * layout against a zero/SSR width. */ export function useElementWidth() { const ref = useRef(null); const [width, setWidth] = useState(0); const [mounted, setMounted] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const observer = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 0; setWidth(w); setMounted(true); }); observer.observe(el); return () => observer.disconnect(); }, []); return { width, ref, mounted }; } /** * Lets a nested element break out to the full width of the viewport. * * The pure-CSS `left:50%; margin-left:-50vw` trick only works when every * ancestor is horizontally centered, which is not true here -- Profile's * content column is left-aligned inside the centered page wrapper. Measuring * the real offset is exact from any nesting depth, and using * `documentElement.clientWidth` instead of `100vw` excludes the scrollbar, * so this can't induce a horizontal scroll. * * Returns a style that is a no-op until the first measurement lands. */ export function useFullBleed() { const ref = useRef(null); const [bleed, setBleed] = useState<{ width: number; offsetLeft: number } | null>(null); useEffect(() => { const el = ref.current; if (!el) return; const measure = () => { const width = document.documentElement.clientWidth; // Undo any margin this element is already carrying, so re-measuring is idempotent. const current = parseFloat(el.style.marginLeft || "0") || 0; const offsetLeft = el.getBoundingClientRect().left - current; setBleed((prev) => prev && prev.width === width && Math.abs(prev.offsetLeft - offsetLeft) < 0.5 ? prev : { width, offsetLeft } ); }; measure(); const observer = new ResizeObserver(measure); observer.observe(document.documentElement); window.addEventListener("resize", measure); return () => { observer.disconnect(); window.removeEventListener("resize", measure); }; }, []); const style: CSSProperties = bleed ? { width: bleed.width, marginLeft: -bleed.offsetLeft } : { width: "100%", marginLeft: 0 }; return { ref, style, measured: bleed !== null }; } /** Mirrors the user's `prefers-reduced-motion` setting, and follows changes to it. */ export function usePrefersReducedMotion() { const [reduced, setReduced] = useState(false); useEffect(() => { const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const sync = () => setReduced(query.matches); sync(); query.addEventListener("change", sync); return () => query.removeEventListener("change", sync); }, []); return reduced; } export function useMountTransition(delay = 50) { const [mounted, setMounted] = useState(false); useEffect(() => { const t = setTimeout(() => setMounted(true), delay); return () => clearTimeout(t); }, [delay]); return mounted; }