← Catalog
Edit Props (live)
TEXT
TEXT
config
config
config
config
config
config
config
config
Source
208 linesimport 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_cardStat Card — Andy's hand-crafted Remotion scene. 10 props. caption:TEXT, textColor:TEXT.
Workflow Inputs (2)
- Caption
captionTEXT - Text Color
textColorTEXT
Config Fields (8)
labelvalueprefixsuffixaccentColorsplitHeadSrcheadStartFramebackgroundColor
Meta
- Updated
- 4/21/2026