From d7966580f556a806960839bbd552df941188060c Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 24 Jan 2026 12:51:11 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AF=20Add=20StatsCounter=20component?= =?UTF-8?q?=20with=20animated=20number=20counting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- videos/src/components/StatsCounter.test.tsx | 96 +++++++++++++++ videos/src/components/StatsCounter.tsx | 128 ++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 videos/src/components/StatsCounter.test.tsx create mode 100644 videos/src/components/StatsCounter.tsx diff --git a/videos/src/components/StatsCounter.test.tsx b/videos/src/components/StatsCounter.test.tsx new file mode 100644 index 0000000..5c2f5d8 --- /dev/null +++ b/videos/src/components/StatsCounter.test.tsx @@ -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(); + expect(container).toBeInTheDocument(); + }); + + it("displays the default target number (204)", () => { + const { container } = render(); + const numberElement = container.querySelector(".tabular-nums"); + expect(numberElement).toHaveTextContent("204"); + }); + + it("displays a custom target number", () => { + render(); + expect(screen.getByText("500")).toBeInTheDocument(); + }); + + it("displays a label when provided", () => { + render(); + expect(screen.getByText("Active Members")).toBeInTheDocument(); + }); + + it("displays prefix when provided", () => { + render(); + expect(screen.getByText("$100")).toBeInTheDocument(); + }); + + it("displays suffix when provided", () => { + render(); + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + + it("displays prefix and suffix together", () => { + render(); + expect(screen.getByText("~50+")).toBeInTheDocument(); + }); + + it("displays decimal places when specified", () => { + render(); + expect(screen.getByText("3.14")).toBeInTheDocument(); + }); + + it("applies custom color style", () => { + const { container } = render(); + const numberElement = container.querySelector(".tabular-nums"); + expect(numberElement).toHaveStyle({ color: "#00ff00" }); + }); + + it("applies custom font size", () => { + const { container } = render(); + const numberElement = container.querySelector(".tabular-nums"); + expect(numberElement).toHaveStyle({ fontSize: "200px" }); + }); + + it("does not render label when not provided", () => { + const { container } = render(); + const labels = container.querySelectorAll(".text-zinc-300"); + expect(labels.length).toBe(0); + }); + + it("uses tabular-nums class for consistent number width", () => { + const { container } = render(); + const numberElement = container.querySelector(".tabular-nums"); + expect(numberElement).toBeInTheDocument(); + }); +}); diff --git a/videos/src/components/StatsCounter.tsx b/videos/src/components/StatsCounter.tsx new file mode 100644 index 0000000..8a049f4 --- /dev/null +++ b/videos/src/components/StatsCounter.tsx @@ -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 = ({ + 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 ( +
+ {/* Number with glow effect */} +
+ {prefix} + {displayNumber} + {suffix} +
+ + {/* Label */} + {label && ( +
+ {label} +
+ )} +
+ ); +};