reelcn
GitHubBack to landing

Stat counter

Single metric: a label above a big counting number, an optional up/down delta badge beside it, and a caption underneath.

StatCounter, mrr
StatCounter, mrr thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/stat-counter.json

Files land in src/reelcn/ with the items it builds on.

Usage

<Center>
  <StatCounter
    label="Monthly recurring revenue"
    to={48200}
    format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
    delta={12.4}
    caption="vs. last month"
  />
</Center>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
label*string
to*number
fromnumber Default 0.
formatNumberFormatOptionsIntl.NumberFormat options. Fraction digits default to however many from and to have.
localestringDefaults to "en-US", so renders match on every machine. Default "en-US".
prefixstring Default "".
suffixstring Default "".
sizenumberFont size of the number in design units. Default 108.
weightnumber
font"mono" | "heading" | "body" Default "heading".
colorstring
rollingbooleanOdometer-style digit wheels instead of a counting number. Default false.
align"left" | "right" | "center" Default "center".
deltanumberPercentage change, e.g. `12.4` for "+12.4%" or -3 for "-3%". Omit to hide the badge.
positiveColorstringBadge color when delta is zero or positive.
negativeColorstringBadge color when delta is negative.
captionstringSmall line under the number, such as "vs. last month".
styleCSSProperties
classNamestring

Built from

Use

  • A headline KPI on its own slide: revenue, active users, churn
  • A metric that should show change since last period

Avoid

  • Several metrics together. Use kpi-grid
  • A bare number with no label. Use counter

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/stat-counter.tsx
/**
 * @title Stat Counter
 * @category data
 * @description Single metric: a label above a big counting number, an optional up/down delta badge beside it, and a caption underneath.
 * @duration 60
 * @use A headline KPI on its own slide: revenue, active users, churn
 * @use A metric that should show change since last period
 * @avoid Several metrics together. Use `kpi-grid`
 * @avoid A bare number with no label. Use `counter`
 * @tags stat, kpi, metric, delta, counter, data
 * @example
 * <Center>
 *   <StatCounter
 *     label="Monthly recurring revenue"
 *     to={48200}
 *     format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
 *     delta={12.4}
 *     caption="vs. last month"
 *   />
 * </Center>
 */
import type React from "react";
import { decimals } from "./chart-scale";
import { alpha, type MotionProps, tween, useMotion, useTheme, useViewport } from "./core";
import { Counter } from "./counter";

export type StatCounterProps = MotionProps & {
  label: string;
  to: number;
  from?: number;
  /** Intl.NumberFormat options. Fraction digits default to however many `from` and `to` have. */
  format?: Intl.NumberFormatOptions;
  /** Defaults to "en-US", so renders match on every machine. */
  locale?: string;
  prefix?: string;
  suffix?: string;
  /** Font size of the number in design units. */
  size?: number;
  weight?: number;
  font?: "heading" | "body" | "mono";
  color?: string;
  /** Odometer-style digit wheels instead of a counting number. */
  rolling?: boolean;
  align?: "left" | "center" | "right";
  /** Percentage change, e.g. `12.4` for "+12.4%" or `-3` for "-3%". Omit to hide the badge. */
  delta?: number;
  /** Badge color when `delta` is zero or positive. */
  positiveColor?: string;
  /** Badge color when `delta` is negative. */
  negativeColor?: string;
  /** Small line under the number, such as "vs. last month". */
  caption?: string;
  style?: React.CSSProperties;
  className?: string;
};

const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);

export function StatCounter({
  label,
  to,
  from = 0,
  format,
  locale = "en-US",
  prefix = "",
  suffix = "",
  size = 108,
  weight,
  font = "heading",
  color,
  rolling = false,
  align = "center",
  delta,
  positiveColor,
  negativeColor,
  caption,
  style,
  className,
  ...motion
}: StatCounterProps) {
  const theme = useTheme();
  const posColor = positiveColor ?? theme.colors.success;
  const negColor = negativeColor ?? theme.colors.danger;
  const { u } = useViewport();
  const m = useMotion(motion);
  const step = Math.round(m.fps * 0.12);
  const labelP = clamp01(tween(m.frame, m.fps, { from: m.delay, duration: m.enterFrames, motion: m.preset }));
  const deltaP = clamp01(
    tween(m.frame, m.fps, { from: m.delay + step * 4, duration: m.enterFrames, motion: m.preset }),
  );
  const up = (delta ?? 0) >= 0;
  const deltaColor = up ? posColor : negColor;
  const places = decimals([delta ?? 0]);
  const deltaFormat = new Intl.NumberFormat(locale, {
    style: "percent",
    minimumFractionDigits: places,
    maximumFractionDigits: places,
  });
  const items = align === "left" ? "flex-start" : align === "right" ? "flex-end" : "center";

  return (
    <div
      className={className}
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: items,
        gap: u(8),
        fontFamily: theme.fonts.body,
        textAlign: align,
        opacity: 1 - m.exit,
        translate: `0 ${m.exit * u(20)}px`,
        ...style,
      }}
    >
      <div
        style={{
          fontSize: u(26),
          lineHeight: 1.1,
          fontWeight: 600,
          letterSpacing: "0.01em",
          color: theme.colors.muted,
          opacity: labelP,
          translate: `0 ${(1 - labelP) * u(10)}px`,
        }}
      >
        {label}
      </div>
      <div style={{ display: "flex", alignItems: "baseline", gap: u(16) }}>
        <Counter
          to={to}
          from={from}
          format={format}
          locale={locale}
          prefix={prefix}
          suffix={suffix}
          size={size}
          weight={weight}
          font={font}
          color={color}
          rolling={rolling}
          align={align}
          {...motion}
          exit={false}
        />
        {delta !== undefined && (
          <div
            style={{
              display: "flex",
              alignItems: "center",
              gap: u(4),
              padding: `${u(4)}px ${u(12)}px`,
              borderRadius: u(theme.radius * 0.6),
              background: alpha(deltaColor, 0.16),
              opacity: deltaP,
              translate: `0 ${(1 - deltaP) * u(10)}px`,
            }}
          >
            <svg width={u(16)} height={u(16)} viewBox="0 0 10 10">
              <title>{up ? "up" : "down"}</title>
              <polygon points={up ? "5,1 9,8 1,8" : "5,9 1,2 9,2"} fill={deltaColor} />
            </svg>
            <span style={{ fontSize: u(24), lineHeight: 1, fontWeight: 700, color: deltaColor }}>
              {deltaFormat.format(Math.abs(delta) / 100)}
            </span>
          </div>
        )}
      </div>
      {caption && (
        <div style={{ fontSize: u(22), lineHeight: 1.2, color: theme.colors.muted, opacity: labelP }}>{caption}</div>
      )}
    </div>
  );
}

On this page