138 lines
4.2 KiB
TypeScript

"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<number> 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<T extends HTMLElement>(
ref: React.RefObject<T>,
): MotionValue<number> {
const { scrollY } = useScrollContext();
const progress = useMotionValue(0);
const viewportHRef = useRef<number>(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<number>).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<T extends HTMLElement>(ref: React.RefObject<T>, speed = 0.2, axis: "y" | "x" = "y") {
const { scrollY } = useScrollContext();
const offset = useMotionValue(0);
const baseRef = useRef<number | null>(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<number>).get());
};
window.addEventListener("resize", onResize);
const unsub = (scrollY as MotionValue<number>).on("change", update);
// Initialize
update((scrollY as MotionValue<number>).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<number>; y?: MotionValue<number> };
}