132 lines
3.2 KiB
TypeScript
132 lines
3.2 KiB
TypeScript
"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 };
|
|
}
|