Catalog
No preview yet

Source

206 lines
// KPI Callout Block — large headline number that counts up from zero,
// with a label above and a delta/sub-text below. Useful for stat
// reveals in tech, news, or marketing reels: "$500M saved", "30,000
// laid off", "65% AI-written code".
//
// The count-up easing is springy without a real spring (cheap cubic
// out interpolation) so it feels punchy without an extra dependency.
// Optional fancy formatter handles common cases — currency, percent,
// abbreviated thousands/millions/billions.

import {
  AbsoluteFill,
  Easing,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
import { loadFont as loadJetBrains } from '@remotion/google-fonts/JetBrainsMono';
import { loadFont } from '@remotion/google-fonts/SpaceGrotesk';

const { fontFamily: SPACE_GROTESK } = loadFont();
const { fontFamily: INTER } = loadInter();
const { fontFamily: JETBRAINS } = loadJetBrains();

const FONT_MAP: Record<string, string> = {
  SpaceGrotesk: SPACE_GROTESK,
  Inter: INTER,
  JetBrainsMono: JETBRAINS,
};

export type KpiCalloutProps = {
  label: string;
  targetValue: number;
  prefix: string;
  suffix: string;
  format:
    | 'plain'
    | 'thousands'
    | 'millions'
    | 'billions'
    | 'currency'
    | 'percent';
  sublabel: string;
  countUpDurationS: number;
  numberFont: 'SpaceGrotesk' | 'Inter' | 'JetBrainsMono';
  numberColor: string;
  numberFontSize: number;
  labelColor: string;
  labelFontSize: number;
  sublabelColor: string;
  sublabelFontSize: number;
  backgroundColor: string;
  accentColor: string;
};

function formatNumber(value: number, fmt: KpiCalloutProps['format']): string {
  switch (fmt) {
    case 'currency':
      return value.toLocaleString('en-US', { maximumFractionDigits: 0 });
    case 'thousands':
      return Math.round(value / 1_000).toLocaleString('en-US') + 'K';
    case 'millions':
      return (value / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
    case 'billions':
      return (value / 1_000_000_000).toFixed(1).replace(/\.0$/, '') + 'B';
    case 'percent':
      return Math.round(value).toString();
    case 'plain':
    default:
      return Math.round(value).toLocaleString('en-US');
  }
}

export default function KpiCallout({
  label = 'JOBS LOST',
  targetValue = 30000,
  prefix = '',
  suffix = '',
  format = 'plain',
  sublabel = 'in 2026 alone',
  countUpDurationS = 1.4,
  numberFont = 'SpaceGrotesk',
  numberColor = '#FFFFFF',
  numberFontSize = 200,
  labelColor = '#FFD84D',
  labelFontSize = 32,
  sublabelColor = '#A6A8B0',
  sublabelFontSize = 28,
  backgroundColor = '#0a0b0f',
  accentColor = '#00F0FF',
}: Partial<KpiCalloutProps>) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const numberFamily = FONT_MAP[numberFont] ?? SPACE_GROTESK;
  const countFrames = Math.max(1, Math.round(countUpDurationS * fps));

  const progress = interpolate(frame, [0, countFrames], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.out(Easing.cubic),
  });
  const currentValue = targetValue * progress;
  const numberText = `${prefix}${formatNumber(currentValue, format)}${suffix}`;

  const numberPop = spring({
    frame,
    fps,
    config: { damping: 12, stiffness: 180, mass: 0.5 },
  });
  const labelOpacity = interpolate(frame, [0, 8], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const sublabelOpacity = interpolate(
    frame,
    [countFrames, countFrames + 6],
    [0, 1],
    {
      extrapolateLeft: 'clamp',
      extrapolateRight: 'clamp',
    }
  );
  const sublabelLift = interpolate(
    frame,
    [countFrames, countFrames + 8],
    [12, 0],
    {
      extrapolateLeft: 'clamp',
      extrapolateRight: 'clamp',
      easing: Easing.out(Easing.cubic),
    }
  );

  return (
    <AbsoluteFill
      style={{
        backgroundColor,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        flexDirection: 'column',
        padding: '0 64px',
      }}
    >
      {/* Top accent bar that grows out from center as the number lands */}
      <div
        style={{
          width: `${progress * 80}px`,
          height: 4,
          backgroundColor: accentColor,
          borderRadius: 2,
          marginBottom: 28,
          boxShadow: `0 0 20px ${accentColor}80`,
        }}
      />
      <div
        style={{
          color: labelColor,
          fontFamily: INTER,
          fontWeight: 600,
          fontSize: labelFontSize,
          letterSpacing: '0.18em',
          textTransform: 'uppercase',
          opacity: labelOpacity,
          marginBottom: 18,
        }}
      >
        {label}
      </div>
      <div
        style={{
          color: numberColor,
          fontFamily: numberFamily,
          fontWeight: 800,
          fontSize: numberFontSize,
          lineHeight: 1.0,
          letterSpacing: '-0.04em',
          fontVariantNumeric: 'tabular-nums',
          transform: `scale(${numberPop})`,
          textShadow: '0 6px 24px rgba(0,0,0,0.4)',
        }}
      >
        {numberText}
      </div>
      {sublabel ? (
        <div
          style={{
            color: sublabelColor,
            fontFamily: INTER,
            fontWeight: 500,
            fontSize: sublabelFontSize,
            marginTop: 24,
            opacity: sublabelOpacity,
            transform: `translateY(${sublabelLift}px)`,
            textAlign: 'center',
          }}
        >
          {sublabel}
        </div>
      ) : null}
    </AbsoluteFill>
  );
}

Schema (Zod)

import { z } from 'zod';

export const Schema = z.object({
  label: z.string().default('JOBS LOST').describe('Label above the number'),
  targetValue: z.number().default(30000).describe('Number to count up to'),
  prefix: z.string().default('').describe('e.g. "$"'),
  suffix: z.string().default('').describe('e.g. "%"'),
  format: z
    .enum(['plain', 'thousands', 'millions', 'billions', 'currency', 'percent'])
    .default('plain')
    .describe('$style:select Number format'),
  sublabel: z
    .string()
    .default('in 2026 alone')
    .describe('Sub-line below the number'),
  countUpDurationS: z
    .number()
    .default(1.4)
    .describe('$style:scale Count-up duration (seconds)'),

  numberFont: z
    .enum(['SpaceGrotesk', 'Inter', 'JetBrainsMono'])
    .default('SpaceGrotesk')
    .describe('$style:font Number font'),
  numberColor: z
    .string()
    .default('#FFFFFF')
    .describe('$style:color Number color'),
  numberFontSize: z
    .number()
    .default(200)
    .describe('$style:size Number font size'),

  labelColor: z
    .string()
    .default('#FFD84D')
    .describe('$style:color Label color'),
  labelFontSize: z.number().default(32).describe('$style:size Label font size'),

  sublabelColor: z
    .string()
    .default('#A6A8B0')
    .describe('$style:color Sublabel color'),
  sublabelFontSize: z
    .number()
    .default(28)
    .describe('$style:size Sublabel font size'),

  backgroundColor: z
    .string()
    .default('#0a0b0f')
    .describe('$style:color Background color'),
  accentColor: z
    .string()
    .default('#00F0FF')
    .describe('$style:color Accent bar color'),
});

KPI Callout

kpi_callout

Big stat reveal — number counts up from zero with springy pop, label above, sublabel below. Useful for "$500M saved", "30K layoffs", "65% AI-written" moments.

Workflow Inputs (0)

This block has no workflow-connected inputs.

Config Fields (16)

  • label
  • format
  • prefix
  • suffix
  • sublabel
  • labelColor
  • numberFont
  • accentColor
  • numberColor
  • targetValue
  • labelFontSize
  • sublabelColor
  • numberFontSize
  • backgroundColor
  • countUpDurationS
  • sublabelFontSize

Meta

Updated
7/10/2026