← Catalog
Edit Props (live)
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config
Source
241 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", "800", "900"] });
interface CounterRevealProps {
counterTo: number; // final value
counterFrom?: number; // default 0
prefix?: string; // e.g. "$"
suffix?: string; // e.g. "/mo" or "%"
label?: string; // small text below, e.g. "monthly revenue"
captionText?: string; // small text above
backgroundColor?: string; // default "#0a0612"
accentColor?: string; // confetti/glow color, default "#57CEB2"
textColor?: string; // default "#ffffff"
durationS?: number; // how long the count takes, default 1.8s
formatThousands?: boolean; // insert commas, default true
splitHeadSrc?: string | null;
headStartFrame?: number;
}
const FONT = "'Inter', sans-serif";
const fmtNumber = (n: number, withCommas: boolean) => {
const rounded = Math.round(n);
if (!withCommas) return String(rounded);
return rounded.toLocaleString("en-US");
};
export const CounterReveal: React.FC<CounterRevealProps> = ({
counterTo,
counterFrom = 0,
prefix = "",
suffix = "",
label,
captionText,
backgroundColor = "#0a0612",
accentColor = "#57CEB2",
textColor = "#ffffff",
durationS = 1.8,
formatThousands = true,
splitHeadSrc,
headStartFrame,
}) => {
const frame = useCurrentFrame();
const { fps, durationInFrames, width, height } = useVideoConfig();
const countStartFrame = Math.floor(fps * 0.4);
const countEndFrame = countStartFrame + Math.floor(fps * durationS);
const countT = interpolate(frame, [countStartFrame, countEndFrame], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const currentValue = counterFrom + (counterTo - counterFrom) * countT;
// Scale punch when the count finishes
const punchSpring = spring({
frame: frame - countEndFrame,
fps,
config: { mass: 0.8, damping: 10, stiffness: 140 },
});
const settleScale = interpolate(punchSpring, [0, 1], [1.08, 1]);
// Initial entry
const enter = interpolate(frame, [0, 14], [0.6, 1], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const opIn = interpolate(frame, [0, 14], [0, 1], { extrapolateRight: "clamp" });
const scale = countT < 1 ? enter : settleScale;
// Glow intensifies at settle
const glowIntensity = interpolate(frame, [countEndFrame, countEndFrame + 10], [0.3, 1], {
extrapolateRight: "clamp",
});
// Radial burst on settle
const burstT = spring({
frame: frame - countEndFrame,
fps,
config: { mass: 0.7, damping: 18, stiffness: 130 },
});
const burstScale = interpolate(burstT, [0, 1], [0.4, 1.6]);
const burstOp = interpolate(burstT, [0, 0.3, 1], [0, 0.8, 0]);
// Caption fade in
const capOp = interpolate(frame, [2, 14], [0, 1], { extrapolateRight: "clamp" });
const capY = interpolate(frame, [2, 14], [10, 0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
// Label fade in once counting starts
const labelOp = interpolate(frame, [countStartFrame + 8, countStartFrame + 22], [0, 1], {
extrapolateRight: "clamp",
});
// Font size auto-scales with the string length
const valueStr = `${prefix}${fmtNumber(currentValue, formatThousands)}${suffix}`;
const valueFontSize = Math.min(280, Math.floor((width - 160) / Math.max(valueStr.length, 4) * 1.4));
const split = Boolean(splitHeadSrc);
const fullFrame = (
<AbsoluteFill style={{ backgroundColor, overflow: "hidden" }}>
{/* Background gradient wash */}
<div
style={{
position: "absolute",
inset: 0,
background: `radial-gradient(ellipse at 50% 50%, ${accentColor}22 0%, transparent 55%)`,
opacity: glowIntensity,
pointerEvents: "none",
}}
/>
{/* Radial burst on settle */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: width * 1.2,
height: width * 1.2,
transform: `translate(-50%, -50%) scale(${burstScale})`,
borderRadius: "50%",
border: `12px solid ${accentColor}`,
opacity: burstOp,
pointerEvents: "none",
}}
/>
{/* Caption */}
{captionText && (
<div
style={{
position: "absolute",
top: 200,
left: 0,
right: 0,
textAlign: "center",
fontFamily: FONT,
fontSize: 54,
fontWeight: 500,
fontStyle: "italic",
color: textColor,
opacity: capOp,
transform: `translateY(${capY}px)`,
padding: "0 60px",
}}
>
{captionText}
</div>
)}
{/* Big number, centered */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${scale})`,
opacity: opIn,
fontFamily: FONT,
fontSize: valueFontSize,
fontWeight: 900,
color: textColor,
letterSpacing: -6,
lineHeight: 1,
textAlign: "center",
textShadow: `0 0 ${40 * glowIntensity}px ${accentColor}88`,
whiteSpace: "nowrap",
}}
>
{valueStr}
</div>
{/* Label below */}
{label && (
<div
style={{
position: "absolute",
bottom: 240,
left: 0,
right: 0,
textAlign: "center",
fontFamily: FONT,
fontSize: 52,
fontWeight: 700,
color: accentColor,
letterSpacing: -0.8,
opacity: labelOp,
padding: "0 60px",
}}
>
{label}
</div>
)}
</AbsoluteFill>
);
if (split && splitHeadSrc) {
return (
<SplitHeadBelow
headSrc={splitHeadSrc}
backgroundColor={backgroundColor}
topBackground={backgroundColor}
headStartFrame={headStartFrame}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: 1080,
height: 1920,
transform: "scaleY(0.5)",
transformOrigin: "top left",
}}
>
{fullFrame}
</div>
</SplitHeadBelow>
);
}
return fullFrame;
};
Schema (Zod)
// Auto-extracted from Props interface — see source for authoritative types.
// Component: CounterReveal
// 13 props detected · 2 port(s)
Counter Reveal
counter_revealCounter Reveal — Andy's hand-crafted Remotion scene. 13 props. captionText:TEXT, textColor:TEXT.
Workflow Inputs (2)
- Caption Text
captionTextTEXT - Text Color
textColorTEXT
Config Fields (11)
labelprefixsuffixcounterTodurationSaccentColorcounterFromsplitHeadSrcheadStartFramebackgroundColorformatThousands
Meta
- Updated
- 4/21/2026