Files
LexiChain/hooks/useScrollAnimation.ts

132 lines
3.2 KiB
TypeScript
Raw Normal View History

2026-02-14 21:47:08 +01:00
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
interface UseScrollAnimationOptions {
threshold?: number;
rootMargin?: string;
triggerOnce?: boolean;
}
export function useScrollAnimation<T extends HTMLElement = HTMLDivElement>(
options: UseScrollAnimationOptions = {},
) {
const { threshold = 0.1, rootMargin = "0px", triggerOnce = true } = options;
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasTriggered, setHasTriggered] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return; // extra safety
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
if (triggerOnce) {
setHasTriggered(true);
observer.unobserve(element);
}
} else if (!triggerOnce) {
setIsVisible(false);
}
},
{ threshold, rootMargin },
);
observer.observe(element);
return () => observer.disconnect();
}, [threshold, rootMargin, triggerOnce]);
const reset = useCallback(() => {
setIsVisible(false);
setHasTriggered(false);
}, []);
return {
ref,
isVisible: triggerOnce ? isVisible || hasTriggered : isVisible,
reset,
};
}
export function useCountUp(
end: number,
duration: number = 2000,
start: number = 0,
) {
const [count, setCount] = useState(start);
const [isAnimating, setIsAnimating] = useState(false);
const countRef = useRef(start);
const startAnimation = useCallback(() => {
if (isAnimating) return;
setIsAnimating(true);
const startTime = Date.now();
const range = end - start;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const currentCount = Math.floor(start + range * easeOut);
countRef.current = currentCount;
setCount(currentCount);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setCount(end);
setIsAnimating(false);
}
};
requestAnimationFrame(animate);
}, [end, duration, start, isAnimating]);
const reset = useCallback(() => {
setCount(start);
setIsAnimating(false);
}, [start]);
return { count, startAnimation, reset, isAnimating };
}
export function useParallax(speed: number = 0.5) {
const ref = useRef<HTMLDivElement>(null);
const [offset, setOffset] = useState(0);
useEffect(() => {
if (typeof window === "undefined") return;
const handleScroll = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const scrolled = window.scrollY;
const elementTop = rect.top + scrolled;
const relativeScroll = scrolled - elementTop + window.innerHeight;
setOffset(relativeScroll * speed * 0.1);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [speed]);
return { ref, offset };
}