138 lines
4.2 KiB
TypeScript
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> };
|
|
}
|