diff --git a/videos/src/components/SparklineChart.test.tsx b/videos/src/components/SparklineChart.test.tsx
new file mode 100644
index 0000000..8ff77cd
--- /dev/null
+++ b/videos/src/components/SparklineChart.test.tsx
@@ -0,0 +1,178 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render } from "@testing-library/react";
+import { SparklineChart } from "./SparklineChart";
+
+// Mock Remotion hooks
+vi.mock("remotion", () => ({
+ useCurrentFrame: vi.fn(() => 60), // Midway through animation
+ useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })),
+ interpolate: vi.fn((value, inputRange, outputRange, options) => {
+ // Simple linear interpolation mock
+ const [inMin, inMax] = inputRange;
+ const [outMin, outMax] = outputRange;
+ let progress = (value - inMin) / (inMax - inMin);
+ if (options?.extrapolateLeft === "clamp") {
+ progress = Math.max(0, progress);
+ }
+ if (options?.extrapolateRight === "clamp") {
+ progress = Math.min(1, progress);
+ }
+ 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("SparklineChart", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it("renders without errors", () => {
+ const { container } = render();
+ expect(container).toBeInTheDocument();
+ });
+
+ it("renders an SVG element", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ });
+
+ it("renders with default dimensions", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toHaveAttribute("width", "100");
+ expect(svg).toHaveAttribute("height", "30");
+ });
+
+ it("renders with custom dimensions", () => {
+ const { container } = render(
+
+ );
+ const svg = container.querySelector("svg");
+ expect(svg).toHaveAttribute("width", "200");
+ expect(svg).toHaveAttribute("height", "50");
+ });
+
+ it("renders a path element for the line", () => {
+ const { container } = render();
+ const paths = container.querySelectorAll("path");
+ // At least one path for the main line
+ expect(paths.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("applies custom stroke color", () => {
+ const { container } = render(
+
+ );
+ const path = container.querySelector('path[stroke="#00ff00"]');
+ expect(path).toBeInTheDocument();
+ });
+
+ it("applies custom stroke width", () => {
+ const { container } = render(
+
+ );
+ const path = container.querySelector('path[stroke-width="4"]');
+ expect(path).toBeInTheDocument();
+ });
+
+ it("returns null for empty data", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeNull();
+ });
+
+ it("handles single data point", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ const path = container.querySelector("path");
+ expect(path).toBeInTheDocument();
+ });
+
+ it("renders fill gradient when showFill is true", () => {
+ const { container } = render(
+
+ );
+ const linearGradient = container.querySelector("linearGradient");
+ expect(linearGradient).toBeInTheDocument();
+ });
+
+ it("renders glow filter when showGlow is true (default)", () => {
+ const { container } = render();
+ const filter = container.querySelector("filter");
+ expect(filter).toBeInTheDocument();
+ });
+
+ it("does not render glow filter when showGlow is false", () => {
+ const { container } = render(
+
+ );
+ const filter = container.querySelector("filter");
+ expect(filter).toBeNull();
+ });
+
+ it("uses stroke-dasharray for animation", () => {
+ const { container } = render();
+ const path = container.querySelector('path[fill="none"]');
+ expect(path).toHaveAttribute("stroke-dasharray");
+ expect(path).toHaveAttribute("stroke-dashoffset");
+ });
+
+ it("uses default Bitcoin orange color", () => {
+ const { container } = render();
+ const path = container.querySelector('path[stroke="#f7931a"]');
+ expect(path).toBeInTheDocument();
+ });
+
+ it("renders with correct viewBox", () => {
+ const { container } = render(
+
+ );
+ const svg = container.querySelector("svg");
+ expect(svg).toHaveAttribute("viewBox", "0 0 150 40");
+ });
+
+ it("handles constant data (all same values)", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ const path = container.querySelector("path");
+ expect(path).toBeInTheDocument();
+ });
+
+ it("handles negative values", () => {
+ const { container } = render();
+ const svg = container.querySelector("svg");
+ expect(svg).toBeInTheDocument();
+ });
+
+ it("renders two paths when showFill is enabled", () => {
+ const { container } = render(
+
+ );
+ const paths = container.querySelectorAll("path");
+ expect(paths.length).toBe(2); // One for fill, one for line
+ });
+
+ it("sets stroke-linecap to round", () => {
+ const { container } = render();
+ const path = container.querySelector('path[stroke-linecap="round"]');
+ expect(path).toBeInTheDocument();
+ });
+
+ it("sets stroke-linejoin to round", () => {
+ const { container } = render();
+ const path = container.querySelector('path[stroke-linejoin="round"]');
+ expect(path).toBeInTheDocument();
+ });
+});
diff --git a/videos/src/components/SparklineChart.tsx b/videos/src/components/SparklineChart.tsx
new file mode 100644
index 0000000..37c8378
--- /dev/null
+++ b/videos/src/components/SparklineChart.tsx
@@ -0,0 +1,276 @@
+import {
+ useCurrentFrame,
+ useVideoConfig,
+ interpolate,
+ spring,
+ Easing,
+} from "remotion";
+import { useMemo, useId } from "react";
+
+export interface SparklineChartProps {
+ /** Data points for the sparkline (array of numbers) */
+ data: number[];
+ /** Width of the chart in pixels (default: 100) */
+ width?: number;
+ /** Height of the chart in pixels (default: 30) */
+ height?: number;
+ /** Stroke color (default: #f7931a - Bitcoin orange) */
+ color?: string;
+ /** Stroke width in pixels (default: 2) */
+ strokeWidth?: number;
+ /** Delay in frames before animation starts (default: 0) */
+ delay?: number;
+ /** Duration of the draw animation in frames (default: 45) */
+ duration?: number;
+ /** Whether to use spring animation (default: true) */
+ useSpring?: boolean;
+ /** Whether to show a fill gradient below the line (default: false) */
+ showFill?: boolean;
+ /** Fill opacity (0-1, default: 0.2) */
+ fillOpacity?: number;
+ /** Whether to show a glow effect (default: true) */
+ showGlow?: boolean;
+ /** Padding inside the chart (default: 2) */
+ padding?: number;
+}
+
+/**
+ * Generates SVG path data for a sparkline from data points
+ */
+function generatePath(
+ data: number[],
+ width: number,
+ height: number,
+ padding: number
+): string {
+ if (data.length === 0) return "";
+ if (data.length === 1) {
+ const y = height / 2;
+ return `M ${padding} ${y} L ${width - padding} ${y}`;
+ }
+
+ const min = Math.min(...data);
+ const max = Math.max(...data);
+ const range = max - min || 1;
+
+ const innerWidth = width - padding * 2;
+ const innerHeight = height - padding * 2;
+
+ const points = data.map((value, index) => {
+ const x = padding + (index / (data.length - 1)) * innerWidth;
+ const normalizedValue = (value - min) / range;
+ // Invert Y axis (SVG 0,0 is top-left)
+ const y = padding + (1 - normalizedValue) * innerHeight;
+ return { x, y };
+ });
+
+ // Create path with smooth curves (Catmull-Rom to Bezier conversion)
+ const pathParts = points.map((point, i) => {
+ if (i === 0) {
+ return `M ${point.x} ${point.y}`;
+ }
+ return `L ${point.x} ${point.y}`;
+ });
+
+ return pathParts.join(" ");
+}
+
+/**
+ * Generates a closed path for the fill area below the line
+ */
+function generateFillPath(
+ data: number[],
+ width: number,
+ height: number,
+ padding: number
+): string {
+ if (data.length === 0) return "";
+
+ const linePath = generatePath(data, width, height, padding);
+ if (!linePath) return "";
+
+ // Close the path at the bottom
+ const firstX = padding;
+ const lastX = width - padding;
+ const bottomY = height - padding;
+
+ return `${linePath} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`;
+}
+
+/**
+ * Calculate the total length of an SVG path
+ */
+function calculatePathLength(
+ data: number[],
+ width: number,
+ height: number,
+ padding: number
+): number {
+ if (data.length < 2) return 0;
+
+ const min = Math.min(...data);
+ const max = Math.max(...data);
+ const range = max - min || 1;
+
+ const innerWidth = width - padding * 2;
+ const innerHeight = height - padding * 2;
+
+ let totalLength = 0;
+
+ for (let i = 1; i < data.length; i++) {
+ const x1 = padding + ((i - 1) / (data.length - 1)) * innerWidth;
+ const x2 = padding + (i / (data.length - 1)) * innerWidth;
+
+ const y1 = padding + (1 - (data[i - 1] - min) / range) * innerHeight;
+ const y2 = padding + (1 - (data[i] - min) / range) * innerHeight;
+
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ totalLength += Math.sqrt(dx * dx + dy * dy);
+ }
+
+ return totalLength;
+}
+
+export const SparklineChart: React.FC = ({
+ data,
+ width = 100,
+ height = 30,
+ color = "#f7931a",
+ strokeWidth = 2,
+ delay = 0,
+ duration = 45,
+ useSpring: useSpringAnimation = true,
+ showFill = false,
+ fillOpacity = 0.2,
+ showGlow = true,
+ padding = 2,
+}) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+
+ // Generate path data
+ const pathData = useMemo(
+ () => generatePath(data, width, height, padding),
+ [data, width, height, padding]
+ );
+
+ const fillPathData = useMemo(
+ () => (showFill ? generateFillPath(data, width, height, padding) : ""),
+ [data, width, height, padding, showFill]
+ );
+
+ // Calculate path length for stroke-dasharray animation
+ const pathLength = useMemo(
+ () => calculatePathLength(data, width, height, padding),
+ [data, width, height, padding]
+ );
+
+ // Calculate animation progress
+ const adjustedFrame = Math.max(0, frame - delay);
+
+ let progress: number;
+
+ if (useSpringAnimation) {
+ const springValue = spring({
+ frame: adjustedFrame,
+ fps,
+ config: {
+ damping: 20,
+ stiffness: 80,
+ mass: 1,
+ },
+ durationInFrames: duration,
+ });
+ progress = springValue;
+ } else {
+ progress = interpolate(adjustedFrame, [0, duration], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ easing: Easing.out(Easing.cubic),
+ });
+ }
+
+ // Calculate stroke-dashoffset for line draw animation
+ const dashOffset = interpolate(progress, [0, 1], [pathLength, 0]);
+
+ // Opacity animation for entrance
+ const opacity = interpolate(progress, [0, 0.2], [0, 1], {
+ extrapolateRight: "clamp",
+ });
+
+ // Fill opacity animation (slightly delayed)
+ const fillProgress = interpolate(progress, [0.3, 1], [0, 1], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // Glow intensity
+ const glowIntensity = interpolate(progress, [0.5, 1], [0.3, 0.6], {
+ extrapolateLeft: "clamp",
+ extrapolateRight: "clamp",
+ });
+
+ // Generate unique ID for gradient using React's useId for deterministic IDs
+ const reactId = useId();
+ const gradientId = `sparkline-gradient-${reactId.replace(/:/g, "")}`;
+
+ if (data.length === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};