Files
gridpilot.gg/apps/website/hooks/useScrollProgress.ts
2025-12-25 12:54:08 +01:00

125 lines
3.8 KiB
TypeScript

'use client';
import { useEffect, useState, RefObject } from 'react';
/**
* Calculate scroll progress (0-1) based on element's position in viewport
* @param ref - Reference to the element to track
* @param offset - Offset from viewport edges (0-1, default 0.1)
* @returns progress value between 0 and 1
*/
export function useScrollProgress(ref: RefObject<HTMLElement | null>, offset: number = 0.1): number {
const [progress, setProgress] = useState(0);
useEffect(() => {
if (!ref.current) return;
let rafId: number;
const calculateProgress = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
// Element enters viewport from bottom
const enterPoint = viewportHeight * (1 - offset);
// Element reaches top of viewport
const exitPoint = viewportHeight * offset;
// Calculate progress: 0 when entering, 1 at 30% viewport (accelerated)
const elementCenter = rect.top + rect.height / 2;
const totalDistance = enterPoint - exitPoint;
const currentDistance = enterPoint - elementCenter;
// Accelerate progress to reach 1.0 at 30% viewport height
// Scale factor: 1.67 makes progress reach 1.0 at ~30% instead of 50%
const rawProgress = (currentDistance / totalDistance) * 1.67;
let clampedProgress = Math.max(0, Math.min(1, rawProgress));
// At bottom of page - ensure elements near bottom can reach 100%
// Only apply if we're at the very bottom AND this element is below the fold
if (scrollY + viewportHeight >= documentHeight - 50 && rect.top < viewportHeight) {
clampedProgress = Math.max(clampedProgress, 1);
}
setProgress(clampedProgress);
};
const handleScroll = () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(calculateProgress);
};
// Initial calculation
calculateProgress();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [ref, offset]);
return progress;
}
/**
* Calculate parallax offset based on scroll position
* @param ref - Reference to the element to track
* @param speed - Parallax speed multiplier (default 0.5)
* @returns offset in pixels
*/
export function useParallax(ref: RefObject<HTMLElement | null>, speed: number = 0.5): number {
const [offset, setOffset] = useState(0);
useEffect(() => {
if (!ref.current) return;
let rafId: number;
const calculateOffset = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate offset based on element position relative to viewport
const scrolled = viewportHeight - rect.top;
const parallaxOffset = scrolled * speed;
setOffset(parallaxOffset);
};
const handleScroll = () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(calculateOffset);
};
calculateOffset();
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [ref, speed]);
return offset;
}