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>
This commit is contained in:
senke 2026-02-10 00:19:18 +01:00
parent 6ef9089905
commit 8f26a2b3e9
9 changed files with 143 additions and 10 deletions

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
import type { StatCardProps } from './types';
const colorClasses: Record<StatCardProps['color'], string> = {
@ -60,7 +61,11 @@ export function AdminDashboardStatCard({
)}
</div>
<div className="text-3xl font-display font-bold text-white mb-1 relative z-10">
{value ?? '—'}
{typeof value === 'number' ? (
<AnimatedNumber value={value} />
) : (
value ?? '—'
)}
</div>
<div className="text-xs text-muted-foreground uppercase tracking-widest font-mono relative z-10">
{label}

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Card } from '../ui/card';
import { ArrowUp, ArrowDown } from 'lucide-react';
import { AnimatedNumber } from '../ui/AnimatedNumber';
interface StatCardProps {
label: string;
@ -96,7 +97,11 @@ export const StatCard: React.FC<StatCardProps> = ({
{label}
</p>
<h3 className="text-xl font-semibold text-foreground tracking-tight truncate">
{value}
{typeof value === 'number' ? (
<AnimatedNumber value={value} />
) : (
value
)}
</h3>
</div>
<div className={`p-2 rounded-lg shrink-0 ${bgMap[color]} ${colorMap[color]}`}>

View file

@ -7,6 +7,7 @@ import { PageTransition } from './PageTransition';
import { useUIStore } from '@/stores/ui';
import { cn } from '@/lib/utils';
import { AstralBackground } from '../ui/AstralBackground';
import { NavigationProgress } from '../ui/NavigationProgress';
interface LayoutProps {
children: ReactNode;
@ -17,6 +18,7 @@ export function Layout({ children }: LayoutProps) {
return (
<div className="min-h-screen bg-background text-foreground relative overflow-x-hidden">
<NavigationProgress />
<AstralBackground />
<Header />

View file

@ -0,0 +1,16 @@
import { useAnimatedCounter } from '@/hooks/useAnimatedCounter';
import { cn } from '@/lib/utils';
interface AnimatedNumberProps {
value: number;
duration?: number;
className?: string;
format?: (n: number) => string;
}
export function AnimatedNumber({ value, duration = 1000, className, format }: AnimatedNumberProps) {
const count = useAnimatedCounter({ end: value, duration });
const display = format ? format(count) : count.toLocaleString();
return <span className={cn('tabular-nums', className)}>{display}</span>;
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
export function NavigationProgress() {
const location = useLocation();
const [isNavigating, setIsNavigating] = useState(false);
const [progress, setProgress] = useState(0);
useEffect(() => {
setIsNavigating(true);
setProgress(0);
// Simulate progress
const timer1 = setTimeout(() => setProgress(30), 50);
const timer2 = setTimeout(() => setProgress(60), 150);
const timer3 = setTimeout(() => setProgress(80), 300);
const timer4 = setTimeout(() => {
setProgress(100);
setTimeout(() => setIsNavigating(false), 200);
}, 500);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
clearTimeout(timer3);
clearTimeout(timer4);
};
}, [location.pathname]);
return (
<AnimatePresence>
{isNavigating && (
<motion.div
className="fixed top-0 left-0 right-0 z-[100] h-0.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<motion.div
className="h-full bg-primary shadow-[0_0_10px_var(--primary)]"
initial={{ width: '0%' }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
import { useEffect } from 'react'; // Added useEffect import
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { ContentTransition } from '@/components/ui/content-transition';
import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
function DashboardPage() {
const navigate = useNavigate();
@ -33,7 +34,7 @@ function DashboardPage() {
const stats = [
{
title: 'Tracks Listened',
value: '1,234',
value: 1234,
change: '+12%',
icon: Music,
color: 'text-primary',
@ -41,7 +42,7 @@ function DashboardPage() {
},
{
title: 'Messages Sent',
value: '567',
value: 567,
change: '+8%',
icon: MessageSquare,
color: 'text-success',
@ -49,7 +50,7 @@ function DashboardPage() {
},
{
title: 'Favorites',
value: '89',
value: 89,
change: '+23%',
icon: Heart,
color: 'text-destructive',
@ -57,7 +58,7 @@ function DashboardPage() {
},
{
title: 'Active Friends',
value: '45',
value: 45,
change: '+5%',
icon: Users,
color: 'text-magenta-500',
@ -96,7 +97,7 @@ function DashboardPage() {
<stat.icon className={cn("h-4 w-4 transition-all duration-[var(--duration-normal)]", stat.color, stat.shadow, "group-hover:scale-110")} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white tracking-tight">{stat.value}</div>
<AnimatedNumber value={stat.value} className="text-2xl font-bold text-white tracking-tight" />
<p className="text-xs text-muted-foreground mt-1">
<span className="text-success font-medium">{stat.change}</span> from last month
</p>

View file

@ -2,6 +2,7 @@ import { MapPin, Calendar, User, Music, Library, Users } from 'lucide-react';
import { Avatar } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card';
import { FollowButton } from '../../components/FollowButton';
import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
import type { UserProfile } from '@/services/api/users';
interface UserProfilePageHeaderProps {
@ -113,9 +114,7 @@ export function UserProfilePageHeader({
className="w-4 h-4 mx-auto mb-1.5 text-muted-foreground group-hover/stat:text-primary transition-colors duration-[var(--duration-fast)]"
aria-hidden
/>
<div className="text-2xl font-bold font-display text-white tabular-nums">
{stat.value}
</div>
<AnimatedNumber value={stat.value} className="text-2xl font-bold font-display text-white" />
<div className="text-xs uppercase tracking-wider text-muted-foreground font-bold">
{stat.label}
</div>

View file

@ -23,6 +23,7 @@ export { useGlobalKeyboardShortcuts } from './useGlobalKeyboardShortcuts';
export { usePreload, usePreloadRoute } from './usePreload';
export { useCopyToClipboard } from './useCopyToClipboard';
export { useUnsavedChanges, useFormDirtyState } from './useUnsavedChanges';
export { useAnimatedCounter } from './useAnimatedCounter';
// Hook types
export type {

View file

@ -0,0 +1,53 @@
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;
}