veza/apps/web/src/hooks/useAnimatedCounter.ts
senke 5fe81b9333 feat(ui): animated number counters + navigation progress bar
Animated numbers:
- New useAnimatedCounter hook (requestAnimationFrame, ease-out cubic)
- New AnimatedNumber component with tabular-nums
- Applied to: DashboardPage (4 stats), UserProfilePageHeader (3 stats),
  StatCard, AdminDashboardStatCard (numeric values auto-animate)

Navigation progress bar:
- YouTube/GitHub-style thin bar at top of page
- Simulated progress on route changes (framer-motion)
- Primary color with glow shadow
- Integrated into Layout as first child

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 00:19:18 +01:00

53 lines
1.3 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
interface UseAnimatedCounterOptions {
/** Final value to count to */
end: number;
/** Duration of the animation in ms */
duration?: number;
/** Whether the animation should start (default: true) */
enabled?: boolean;
/** Number of decimal places */
decimals?: number;
}
export function useAnimatedCounter({
end,
duration = 1000,
enabled = true,
decimals = 0,
}: UseAnimatedCounterOptions): number {
const [count, setCount] = useState(0);
const frameRef = useRef<number>();
const startTimeRef = useRef<number>();
useEffect(() => {
if (!enabled || end === 0) {
setCount(end);
return;
}
const animate = (timestamp: number) => {
if (!startTimeRef.current) startTimeRef.current = timestamp;
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
// Ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = eased * end;
setCount(Number(current.toFixed(decimals)));
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate);
}
};
frameRef.current = requestAnimationFrame(animate);
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, [end, duration, enabled, decimals]);
return count;
}