Documentation menu

countup

Scroll-triggered number count-up with reserved width (no layout shift), built-in Intl formatters, and reduced-motion instant-set.

numberanimationscrollinline

Preview

Uncached, you pay $1,285 a month across 42k requests — and 97.4% of them hit the same 12,500-token prefix.

Installation

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco countup component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/countup.json

Manual

Copy each file from the Source section into its target path:

registry/countup/countup.tsx  →  components/moco/countup.tsx
registry/countup/countup.css  →  components/moco/countup.css

Needs moco items: tokens, hooks

Usage

usage.tsx
import { CountUp } from "./countup";

export default function CountUpDemo() {
  return (
    <p style={{ fontFamily: "var(--moco-serif)", color: "var(--moco-body)" }}>
      Uncached, you pay <CountUp value={1284.5} as="usd" /> a month across{" "}
      <CountUp value={42_000} as="compact" /> requests — and{" "}
      <CountUp value={97.4} as="percent" /> of them hit the same{" "}
      <CountUp value={12_500} as="int" />-token prefix.
    </p>
  );
}

Source

components/moco/countup.tsx
"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "@/components/moco/hooks";
import "./countup.css";

const groupedInt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const compactNum = new Intl.NumberFormat("en-US", {
  notation: "compact",
  maximumFractionDigits: 1,
});
const percentNum = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });

/**
 * Built-in formatters, named so the component works from server-rendered
 * prose where a function prop cannot cross the RSC boundary. Client callers
 * can pass `format` instead.
 */
const FORMATS = {
  /** Grouped integer: 12,345. */
  int: (n: number) => groupedInt.format(n),
  /** Currency: $ prefix, digits scale with magnitude ($1,284 / $3.50 / $0.004). */
  usd: (n: number) => {
    const d = Math.abs(n) >= 100 ? 0 : Math.abs(n) >= 1 ? 2 : 3;
    return `$${n.toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d })}`;
  },
  /** Compact: 1.2k / 3.4M. */
  compact: (n: number) => compactNum.format(n).replace("K", "k"),
  /** Percent of a 0–100 value: 42% / 99.9%. */
  percent: (n: number) => `${percentNum.format(n)}%`,
} as const;

export type CountUpFormat = keyof typeof FORMATS;

export type CountUpProps = {
  value: number;
  from?: number;
  /** Animation length in ms. */
  duration?: number;
  /** Built-in formatter name; ignored when `format` is given. */
  as?: CountUpFormat;
  /** Custom formatter — client-side callers only (functions cannot cross the RSC boundary). */
  format?: (n: number) => string;
  className?: string;
};

/**
 * Counts up once, the first time it scrolls into view. Width is reserved from
 * the final value up front so nothing reflows. Disabled under reduced motion.
 */
export function CountUp({
  value,
  from = 0,
  duration = 900,
  as = "int",
  format,
  className,
}: CountUpProps) {
  const fmt = format ?? FORMATS[as];
  const ref = React.useRef<HTMLSpanElement>(null);
  const seen = useInView(ref);
  const reduced = useReducedMotion();
  const [n, setN] = React.useState(value);
  const started = React.useRef(false);

  React.useEffect(() => {
    if (!seen || reduced || started.current) return;
    started.current = true;
    setN(from);
    const t0 = performance.now();
    let raf = 0;
    const tick = (t: number) => {
      const k = Math.min(1, (t - t0) / duration);
      const eased = 1 - Math.pow(1 - k, 3);
      setN(from + (value - from) * eased);
      if (k < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [seen, reduced, from, value, duration]);

  const final = fmt(value);

  return (
    <span
      ref={ref}
      className={`moco-countup${className ? ` ${className}` : ""}`}
      style={{ display: "inline-grid" }}
    >
      {/* Reserves the final width; invisible but laid out. */}
      <span aria-hidden="true" style={{ gridArea: "1/1", visibility: "hidden" }}>
        {final}
      </span>
      <span style={{ gridArea: "1/1" }}>{fmt(n)}</span>
    </span>
  );
}
components/moco/countup.css
/* Mono + tabular figures so digits don't jitter while they animate. */
.moco-countup {
  font-family: var(--moco-mono);
  font-variant-numeric: tabular-nums;
}