Catalog

Edit Props (live)

TEXT
TEXT
config
config
config
config
config
config
config
config

Source

208 lines
import React from "react";
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
  spring,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { SplitHeadBelow } from "./SplitHeadBelow";

loadInter("normal", { weights: ["400", "700", "900"] });
loadInter("italic", { weights: ["400", "900"] });

interface StatCardProps {
  value: number;
  label: string;
  prefix?: string;
  suffix?: string;
  caption?: string; // e.g. "vs last quarter"
  backgroundColor?: string;
  accentColor?: string;
  textColor?: string;
  splitHeadSrc?: string | null;
  headStartFrame?: number;
}

const FONT = "'Inter', sans-serif";

function formatValue(v: number): string {
  if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + "M";
  if (v >= 10_000) return (v / 1000).toFixed(1) + "K";
  if (v >= 1000) return v.toLocaleString();
  if (v % 1 !== 0) return v.toFixed(1);
  return Math.round(v).toString();
}

export const StatCard: React.FC<StatCardProps> = ({
  value,
  label,
  prefix = "",
  suffix = "",
  caption,
  backgroundColor = "#0a0612",
  accentColor = "#57CEB2",
  textColor = "#ffffff",
  splitHeadSrc,
  headStartFrame,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const split = Boolean(splitHeadSrc);
  // In split mode the frame is half-height, so scale down typography.
  const numSize = split ? ((prefix + formatValue(value) + suffix).length >= 6 ? 160 : 200) : ((prefix + formatValue(value) + suffix).length >= 6 ? 260 : 320);
  const labelSize = split ? 38 : 64;
  const ruleMaxW = split ? 160 : 240;
  const padding = split ? 48 : 80;
  const captionSize = split ? 22 : 30;

  // Counter animates 0 → value over ~1s
  const t = interpolate(frame, [8, 32], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });
  // Match final formatting during the ramp — if the final value is a whole
  // integer (500) the intermediate frames shouldn't suddenly have ".9".
  const finalIsInt = value % 1 === 0;
  const rawValue = value * t;
  const displayValue = finalIsInt ? Math.round(rawValue) : rawValue;

  // Big number pop-in spring
  const numSpring = spring({ frame: frame - 3, fps, config: { mass: 0.8, damping: 12 } });
  const numScale = interpolate(numSpring, [0, 1], [0.7, 1]);
  const numOpacity = interpolate(frame, [3, 14], [0, 1], { extrapolateRight: "clamp" });

  // Label slides up
  const labelOpacity = interpolate(frame, [20, 32], [0, 1], { extrapolateRight: "clamp" });
  const labelY = interpolate(frame, [20, 34], [24, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Accent rule draws in under label
  const ruleW = interpolate(frame, [28, 48], [0, ruleMaxW], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Caption fades in last
  const captionOpacity = interpolate(frame, [40, 55], [0, 1], { extrapolateRight: "clamp" });

  const content = (
    <div
      style={{
        position: "absolute",
        inset: 0,
        backgroundColor: split ? backgroundColor : undefined,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        color: textColor,
        fontFamily: FONT,
        padding,
      }}
    >
      {/* Soft accent glow behind number */}
      <div
        style={{
          position: "absolute",
          top: "30%",
          left: "50%",
          width: 900,
          height: 900,
          transform: "translate(-50%, -30%)",
          background: `radial-gradient(circle, ${accentColor}44 0%, transparent 60%)`,
          pointerEvents: "none",
          opacity: numOpacity,
        }}
      />

      {/* Big number — size auto-scales for 1-4 digit values with suffix */}
      <div
        style={{
          fontSize: numSize,
          fontWeight: 900,
          letterSpacing: split ? -6 : -10,
          lineHeight: 1,
          color: accentColor,
          textShadow: `0 8px 60px ${accentColor}66`,
          transform: `scale(${numScale})`,
          opacity: numOpacity,
          fontVariantNumeric: "tabular-nums",
          textAlign: "center",
          maxWidth: "100%",
        }}
      >
        {prefix}
        {formatValue(displayValue)}
        {suffix}
      </div>

      {/* Accent rule */}
      <div
        style={{
          width: ruleW,
          height: split ? 6 : 8,
          background: accentColor,
          marginTop: split ? 20 : 36,
          marginBottom: split ? 20 : 36,
          borderRadius: 4,
        }}
      />

      {/* Label */}
      <div
        style={{
          fontSize: labelSize,
          fontWeight: 900,
          letterSpacing: -1,
          textAlign: "center",
          lineHeight: 1.05,
          opacity: labelOpacity,
          transform: `translateY(${labelY}px)`,
          maxWidth: 900,
        }}
      >
        {label}
      </div>

      {/* Caption */}
      {caption && (
        <div
          style={{
            fontSize: captionSize,
            fontWeight: 400,
            fontStyle: "italic",
            marginTop: split ? 16 : 28,
            opacity: captionOpacity * 0.75,
            textAlign: "center",
          }}
        >
          {caption}
        </div>
      )}
    </div>
  );

  if (split && splitHeadSrc) {
    return (
      <SplitHeadBelow
        headSrc={splitHeadSrc}
        backgroundColor={backgroundColor}
        topBackground={backgroundColor}
        headStartFrame={headStartFrame}
      >
        {content}
      </SplitHeadBelow>
    );
  }

  return (
    <AbsoluteFill style={{ backgroundColor }}>{content}</AbsoluteFill>
  );
};

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: StatCard
// 10 props detected · 2 port(s)

Stat Card

stat_card

Stat Card — Andy's hand-crafted Remotion scene. 10 props. caption:TEXT, textColor:TEXT.

Workflow Inputs (2)

  • Caption
    caption
    TEXT
  • Text Color
    textColor
    TEXT

Config Fields (8)

  • label
  • value
  • prefix
  • suffix
  • accentColor
  • splitHeadSrc
  • headStartFrame
  • backgroundColor

Meta

Updated
4/21/2026