{
  "name": "counter",
  "type": "registry:ui",
  "description": "Animated number with grouping, decimals, odometer digit roll and a reserved width",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "motion-primitive",
    "timing",
    "layout"
  ],
  "files": [
    {
      "path": "registry/bases/default/primitives/counter.tsx",
      "type": "registry:ui",
      "content": "import { useMemo } from \"react\";\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { scaleFont } from \"@/remotion/lib/layout\";\nimport {\n  resolveSpringConfig,\n  type MotionSpring,\n} from \"@/remotion/lib/motion-primitive\";\nimport { EASING_ENTER } from \"@/remotion/lib/timing\";\n\nexport type CounterProps = {\n  /** Value the count starts from. */\n  from?: number;\n  /** Value the count lands on. */\n  to: number;\n  durationInFrames?: number;\n  delayInFrames?: number;\n  /** Fixed decimal places. Also fixes the width, so nothing shifts. */\n  decimals?: number;\n  /** Group thousands with the locale's separator. */\n  grouping?: boolean;\n  /** Locale for grouping and decimal marks. */\n  locale?: string;\n  /** Full override of the number formatting. */\n  format?: (value: number) => string;\n  prefix?: string;\n  suffix?: string;\n  /**\n   * Roll each digit like an odometer instead of swapping it. Lower digits spin\n   * continuously; higher ones only turn over as the ones below them wrap.\n   */\n  roll?: boolean;\n  /** Drive the ramp with a spring instead of the ease-out curve. */\n  spring?: MotionSpring;\n  /** Small scale pop on the frame the number lands. */\n  settle?: boolean;\n  fontSize?: number;\n  fontWeight?: number;\n  color?: string;\n  fontFamily?: string;\n  style?: React.CSSProperties;\n};\n\n/** Frames the landing pop takes. */\nconst SETTLE_FRAMES = 9;\nconst SETTLE_SCALE = 1.03;\n/** A digit only turns over once the digits below it are nearly wrapped. */\nconst CARRY_START = 0.88;\n\nfunction buildFormatter({\n  decimals,\n  grouping,\n  locale,\n  format,\n}: {\n  decimals: number;\n  grouping: boolean;\n  locale?: string;\n  format?: (value: number) => string;\n}): (value: number) => string {\n  if (format) return format;\n\n  const formatter = new Intl.NumberFormat(locale, {\n    minimumFractionDigits: decimals,\n    maximumFractionDigits: decimals,\n    useGrouping: grouping,\n  });\n\n  return (value: number) => formatter.format(value);\n}\n\n/**\n * Animated number.\n *\n * Two things a counter has to get right: it must never resize while it runs —\n * a centred value that jumps a character wider on every tick reads as a bug —\n * and it must decelerate, because a number arriving at constant speed has no\n * moment of landing. The width is reserved from the longest value up front and\n * the ramp is an ease-out.\n */\nexport const Counter: React.FC<CounterProps> = ({\n  from = 0,\n  to,\n  durationInFrames = 60,\n  delayInFrames = 0,\n  decimals = 0,\n  grouping = true,\n  locale,\n  format,\n  prefix = \"\",\n  suffix = \"\",\n  roll = false,\n  spring: springProp,\n  settle = true,\n  fontSize: fontSizeProp,\n  fontWeight = 700,\n  color,\n  fontFamily,\n  style,\n}) => {\n  const frame = useCurrentFrame();\n  const { width, fps } = useVideoConfig();\n  const fontSize = fontSizeProp ?? scaleFont(96, width);\n\n  const formatValue = useMemo(\n    () => buildFormatter({ decimals, grouping, locale, format }),\n    [decimals, grouping, locale, format],\n  );\n\n  const progress = springProp\n    ? spring({\n        frame,\n        fps,\n        config: resolveSpringConfig(springProp),\n        delay: delayInFrames,\n        durationInFrames,\n      })\n    : interpolate(\n        frame,\n        [delayInFrames, delayInFrames + durationInFrames],\n        [0, 1],\n        {\n          easing: EASING_ENTER,\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n        },\n      );\n\n  const raw = interpolate(progress, [0, 1], [from, to]);\n  const value = Number(raw.toFixed(decimals));\n\n  /* The widest string either end of the ramp can produce reserves the box. */\n  const widest = useMemo(() => {\n    const a = formatValue(from);\n    const b = formatValue(to);\n    return a.length >= b.length ? a : b;\n  }, [formatValue, from, to]);\n\n  const landed = delayInFrames + durationInFrames;\n  const pop = settle\n    ? interpolate(\n        frame,\n        [landed - 1, landed + SETTLE_FRAMES * 0.35, landed + SETTLE_FRAMES],\n        [1, SETTLE_SCALE, 1],\n        {\n          easing: EASING_ENTER,\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n        },\n      )\n    : 1;\n\n  const numberStyle: React.CSSProperties = {\n    fontSize,\n    fontWeight,\n    fontVariantNumeric: \"tabular-nums\",\n    lineHeight: 1,\n    display: \"inline-block\",\n    scale: pop,\n    transformOrigin: \"center bottom\",\n    ...(color !== undefined ? { color } : {}),\n    ...(fontFamily !== undefined ? { fontFamily } : {}),\n    ...style,\n  };\n\n  /* Roll keeps a fixed digit count, so prefix and suffix can sit beside it.\n   * A reserved block is right-aligned inside its width, so they have to travel\n   * with the number — parked outside, a short value opens a gap after them. */\n  return (\n    <span style={numberStyle}>\n      {roll ? (\n        <>\n          {prefix}\n          <RollingNumber\n            template={widest}\n            value={value}\n            raw={raw}\n            formatValue={formatValue}\n            fontSize={fontSize}\n          />\n          {suffix}\n        </>\n      ) : (\n        <ReservedNumber\n          template={`${prefix}${widest}${suffix}`}\n          text={`${prefix}${formatValue(value)}${suffix}`}\n        />\n      )}\n    </span>\n  );\n};\n\n/**\n * Holds the width of the longest value the counter can show, with the current\n * value laid over it, so the surrounding line never reflows.\n */\nconst ReservedNumber: React.FC<{ template: string; text: string }> = ({\n  template,\n  text,\n}) => (\n  <span style={{ display: \"inline-grid\", justifyItems: \"end\" }}>\n    <span style={{ gridArea: \"1 / 1\", visibility: \"hidden\" }}>{template}</span>\n    <span style={{ gridArea: \"1 / 1\" }}>{text}</span>\n  </span>\n);\n\n/** Decimal place of each digit slot in a formatted template, e.g. 2 for a hundreds column. */\nfunction placesOf(template: string, decimals: number): number[] {\n  const digitCount = template.replace(/\\D/g, \"\").length;\n  const integerDigits = digitCount - decimals;\n  let seen = 0;\n\n  return Array.from(template, (char) => {\n    if (!/\\d/.test(char)) return Number.NaN;\n    const place = integerDigits - 1 - seen;\n    seen += 1;\n    return place;\n  });\n}\n\nconst RollingNumber: React.FC<{\n  template: string;\n  value: number;\n  raw: number;\n  formatValue: (value: number) => string;\n  fontSize: number;\n}> = ({ template, value, raw, formatValue, fontSize }) => {\n  const decimals = (formatValue(0).split(/[.,]/)[1] ?? \"\").length;\n  const places = useMemo(() => placesOf(template, decimals), [template, decimals]);\n  const rowHeight = Math.round(fontSize * 1.16);\n  const magnitude = Math.abs(value) < 1 ? 0 : Math.floor(Math.log10(Math.abs(value)));\n  const current = formatValue(value);\n\n  return (\n    <span\n      style={{\n        display: \"inline-flex\",\n        alignItems: \"flex-end\",\n        height: rowHeight,\n      }}\n    >\n      {Array.from(template, (char, index) => {\n        const place = places[index];\n\n        if (Number.isNaN(place)) {\n          /* Separators travel with the number, so they follow its own width. */\n          return (\n            <span\n              key={`sep-${index}`}\n              style={{\n                height: rowHeight,\n                display: \"grid\",\n                placeItems: \"center\",\n                opacity: current.length >= template.length - index ? 1 : 0,\n              }}\n            >\n              {char}\n            </span>\n          );\n        }\n\n        const scaled = Math.abs(raw) / 10 ** place;\n        const digit = scaled % 10;\n        const whole = Math.floor(digit);\n        const frac = digit - whole;\n        /* The ones column spins freely; every column above it waits for a carry. */\n        const shaped =\n          place <= 0\n            ? frac\n            : interpolate(frac, [CARRY_START, 1], [0, 1], {\n                extrapolateLeft: \"clamp\",\n                extrapolateRight: \"clamp\",\n              });\n        const offset = (whole + shaped) * rowHeight;\n        /* Leading zeros stay blank until the value actually reaches them. */\n        const lit = place <= magnitude || place <= 0;\n\n        return (\n          <span\n            key={`digit-${index}`}\n            style={{\n              height: rowHeight,\n              overflow: \"hidden\",\n              display: \"inline-block\",\n              opacity: lit ? 1 : 0,\n            }}\n          >\n            <span\n              style={{\n                display: \"block\",\n                translate: `0px ${-offset}px`,\n                willChange: \"transform\",\n              }}\n            >\n              {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((digitFace, row) => (\n                <span\n                  key={`face-${row}`}\n                  style={{\n                    display: \"grid\",\n                    placeItems: \"center\",\n                    height: rowHeight,\n                  }}\n                >\n                  {digitFace}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n};\n"
    }
  ],
  "atlas": {
    "lane": "atoms",
    "drive": "time",
    "tier": "core",
    "tags": [
      "text"
    ]
  }
}