🎯 Add StatsCounter component with animated number counting

Implement reusable animated statistics counter component for Remotion videos
that smoothly animates from 0 to a target number (default: 204).

Features:
- Spring-based or linear animation modes
- Configurable delay, duration, and decimal places
- Support for prefix/suffix (e.g., "$", "%", "+")
- Customizable colors and font sizes
- Glow effect that pulses with counting progress
- Optional label display below the number

Includes comprehensive test suite with 12 passing tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 12:51:11 +01:00
parent b7740a9750
commit d7966580f5
2 changed files with 224 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { StatsCounter } from "./StatsCounter";
// Mock Remotion hooks
vi.mock("remotion", () => ({
useCurrentFrame: vi.fn(() => 0),
useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })),
interpolate: vi.fn((value, inputRange, outputRange) => {
// Simple linear interpolation mock
const [inMin, inMax] = inputRange;
const [outMin, outMax] = outputRange;
const progress = Math.max(0, Math.min(1, (value - inMin) / (inMax - inMin)));
return outMin + progress * (outMax - outMin);
}),
spring: vi.fn(() => 1), // Return 1 for fully animated state
Easing: {
out: vi.fn((fn) => fn),
cubic: vi.fn((t: number) => t),
},
}));
describe("StatsCounter", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it("renders without errors", () => {
const { container } = render(<StatsCounter />);
expect(container).toBeInTheDocument();
});
it("displays the default target number (204)", () => {
const { container } = render(<StatsCounter />);
const numberElement = container.querySelector(".tabular-nums");
expect(numberElement).toHaveTextContent("204");
});
it("displays a custom target number", () => {
render(<StatsCounter targetNumber={500} />);
expect(screen.getByText("500")).toBeInTheDocument();
});
it("displays a label when provided", () => {
render(<StatsCounter label="Active Members" />);
expect(screen.getByText("Active Members")).toBeInTheDocument();
});
it("displays prefix when provided", () => {
render(<StatsCounter targetNumber={100} prefix="$" />);
expect(screen.getByText("$100")).toBeInTheDocument();
});
it("displays suffix when provided", () => {
render(<StatsCounter targetNumber={100} suffix="%" />);
expect(screen.getByText("100%")).toBeInTheDocument();
});
it("displays prefix and suffix together", () => {
render(<StatsCounter targetNumber={50} prefix="~" suffix="+" />);
expect(screen.getByText("~50+")).toBeInTheDocument();
});
it("displays decimal places when specified", () => {
render(<StatsCounter targetNumber={3.14159} decimals={2} />);
expect(screen.getByText("3.14")).toBeInTheDocument();
});
it("applies custom color style", () => {
const { container } = render(<StatsCounter color="#00ff00" />);
const numberElement = container.querySelector(".tabular-nums");
expect(numberElement).toHaveStyle({ color: "#00ff00" });
});
it("applies custom font size", () => {
const { container } = render(<StatsCounter fontSize={200} />);
const numberElement = container.querySelector(".tabular-nums");
expect(numberElement).toHaveStyle({ fontSize: "200px" });
});
it("does not render label when not provided", () => {
const { container } = render(<StatsCounter />);
const labels = container.querySelectorAll(".text-zinc-300");
expect(labels.length).toBe(0);
});
it("uses tabular-nums class for consistent number width", () => {
const { container } = render(<StatsCounter />);
const numberElement = container.querySelector(".tabular-nums");
expect(numberElement).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,128 @@
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
} from "remotion";
export interface StatsCounterProps {
/** The target number to count to (default: 204) */
targetNumber?: number;
/** Delay in frames before animation starts */
delay?: number;
/** Duration of the counting animation in frames (default: 60) */
duration?: number;
/** Label text displayed below the number */
label?: string;
/** Font size for the number in pixels (default: 120) */
fontSize?: number;
/** Whether to use spring animation (smoother) or linear interpolation */
useSpring?: boolean;
/** Prefix to display before the number (e.g., "$", "€") */
prefix?: string;
/** Suffix to display after the number (e.g., "%", "+") */
suffix?: string;
/** Number of decimal places to show (default: 0) */
decimals?: number;
/** Custom color for the number (default: #f7931a - Bitcoin orange) */
color?: string;
}
export const StatsCounter: React.FC<StatsCounterProps> = ({
targetNumber = 204,
delay = 0,
duration = 60,
label,
fontSize = 120,
useSpring: useSpringAnimation = true,
prefix = "",
suffix = "",
decimals = 0,
color = "#f7931a",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Calculate animation progress
const adjustedFrame = Math.max(0, frame - delay);
let progress: number;
if (useSpringAnimation) {
// Spring-based animation for smoother, more natural feel
const springValue = spring({
frame: adjustedFrame,
fps,
config: {
damping: 20,
stiffness: 80,
mass: 1,
},
durationInFrames: duration,
});
progress = springValue;
} else {
// Linear interpolation with easeOut for consistent timing
progress = interpolate(adjustedFrame, [0, duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
}
// Calculate the current displayed number
const currentNumber = progress * targetNumber;
const displayNumber = currentNumber.toFixed(decimals);
// Scale animation for entrance
const scaleSpring = spring({
frame: adjustedFrame,
fps,
config: { damping: 15, stiffness: 100 },
});
const scale = interpolate(scaleSpring, [0, 1], [0.5, 1]);
const opacity = interpolate(scaleSpring, [0, 1], [0, 1]);
// Subtle glow effect that pulses with the counting
const glowIntensity = interpolate(progress, [0, 0.5, 1], [0.3, 0.6, 0.4]);
return (
<div
className="flex flex-col items-center justify-center"
style={{
transform: `scale(${scale})`,
opacity,
}}
>
{/* Number with glow effect */}
<div
className="relative font-bold tabular-nums"
style={{
fontSize,
color,
textShadow: `0 0 ${20 * glowIntensity}px ${color}, 0 0 ${40 * glowIntensity}px ${color}40`,
fontFamily: "Inconsolata, monospace",
letterSpacing: "-0.02em",
}}
>
{prefix}
{displayNumber}
{suffix}
</div>
{/* Label */}
{label && (
<div
className="mt-4 text-zinc-300 font-medium tracking-wide"
style={{
fontSize: fontSize * 0.2,
opacity: interpolate(scaleSpring, [0, 1], [0, 0.9]),
}}
>
{label}
</div>
)}
</div>
);
};