"use client"; import { useEffect, useMemo, useRef } from "react"; import { useMotionValue, type MotionValue } from "motion/react"; import { useScrollContext } from "@/app/providers/LenisProvider"; /** * Math utilities */ export function clamp(n: number, min = 0, max = 1) { return Math.min(max, Math.max(min, n)); } export function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } export function mapRange( inMin: number, inMax: number, outMin: number, outMax: number, v: number, clampOutput = true, ) { const t = (v - inMin) / (inMax - inMin || 1); const m = outMin + (outMax - outMin) * t; return clampOutput ? clamp(m, Math.min(outMin, outMax), Math.max(outMin, outMax)) : m; } /** * Returns a MotionValue that represents normalized progress [0..1] * for the given section element as it scrolls through the viewport. * * Progress is 0 when the section just touches the bottom of viewport and * 1 when it has completely exited at the top. The range used is * (section height + viewport height) to distribute progress smoothly. */ export function useSectionProgress( ref: React.RefObject, ): MotionValue { const { scrollY } = useScrollContext(); const progress = useMotionValue(0); const viewportHRef = useRef(typeof window !== "undefined" ? window.innerHeight : 0); useEffect(() => { const onResize = () => { viewportHRef.current = window.innerHeight; // force update after resize update(); }; window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const update = () => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const viewport = viewportHRef.current || window.innerHeight || 0; const range = rect.height + viewport; // 0 when bottom of viewport touches top of element (rect.top === viewport) // 1 when element's bottom crosses top of viewport (rect.bottom <= 0) const value = clamp((viewport - rect.top) / (range || 1), 0, 1); progress.set(value); }; useEffect(() => { // Subscribe to Lenis-driven scroll updates const unsub = (scrollY as MotionValue).on("change", update); // Initialize update(); return () => { unsub(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, scrollY]); return progress; } /** * Hook to compute parallax offset (in px) tied to page scroll for a given element. * speed: positive moves with scroll (slower if between 0..1), negative moves opposite. * axis: "y" or "x" */ export function useParallax(ref: React.RefObject, speed = 0.2, axis: "y" | "x" = "y") { const { scrollY } = useScrollContext(); const offset = useMotionValue(0); const baseRef = useRef(null); const measureBase = () => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); // document scroll position const docScroll = typeof window !== "undefined" ? window.scrollY : 0; baseRef.current = rect.top + docScroll; }; const update = (sy: number) => { if (baseRef.current === null) measureBase(); if (baseRef.current === null) return; const d = sy - baseRef.current; offset.set(d * speed); }; useEffect(() => { // Measure on mount and on resize to handle layout shifts measureBase(); const onResize = () => { baseRef.current = null; measureBase(); update((scrollY as MotionValue).get()); }; window.addEventListener("resize", onResize); const unsub = (scrollY as MotionValue).on("change", update); // Initialize update((scrollY as MotionValue).get()); return () => { window.removeEventListener("resize", onResize); unsub(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, speed, axis, scrollY]); return useMemo( () => ({ [axis]: offset, }), // eslint-disable-next-line react-hooks/exhaustive-deps [axis, offset], ) as { x?: MotionValue; y?: MotionValue }; }