Catalog

Edit Props (live)

TEXT
TEXT
config
config
config
config
config
config
config
config
config
config
config

Source

241 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", "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_reveal

Counter Reveal — Andy's hand-crafted Remotion scene. 13 props. captionText:TEXT, textColor:TEXT.

Workflow Inputs (2)

  • Caption Text
    captionText
    TEXT
  • Text Color
    textColor
    TEXT

Config Fields (11)

  • label
  • prefix
  • suffix
  • counterTo
  • durationS
  • accentColor
  • counterFrom
  • splitHeadSrc
  • headStartFrame
  • backgroundColor
  • formatThousands

Meta

Updated
4/21/2026