animate(0 → n) · 1.6s · tabular-nums

Number Counter

useMotionValue + animate で KPI 数値を補間し、useTransform でフォーマットする。tabular-nums で桁の reflow を防ぐのが肝。duration は 1–2 秒が読みやすい。

animate(0 → n) · 1.6s · tabular-nums

MRR

¥0

+12.4% MoM

Active users

0

last 28 days

Conversion

0.00%

−0.3pt WoW

/**
 * Number Counter — useMotionValue + animate で KPI 数値を補間する。
 * 移植元: motion-playground app/_sections/NumberCounter.tsx(dark 変換 M-B-2)
 */
import { animate, motion, useMotionValue, useTransform } from 'motion/react';
import { useEffect } from 'react';
import { easings } from '../../../lib/motion-tokens/playground';
import { useLabReplay } from './use-lab-replay';

function Counter({
  to,
  duration,
  format,
  playKey,
}: {
  to: number;
  duration: number;
  format: (n: number) => string;
  playKey: number;
}) {
  const value = useMotionValue(0);
  const display = useTransform(value, (latest) => format(latest));

  useEffect(() => {
    value.set(0);
    const controls = animate(value, to, {
      duration,
      ease: easings.md3Standard,
    });
    return () => controls.stop();
  }, [to, duration, value, playKey]);

  return (
    <motion.span className="font-mono text-3xl font-semibold tabular-nums text-[var(--text-primary)] lg:text-4xl">
      {display}
    </motion.span>
  );
}

const CELL =
  'rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-5';
const CELL_LABEL =
  'mb-1 text-xs font-medium uppercase tracking-[0.1em] text-[var(--text-muted)]';

export default function NumberCounter() {
  const { rootRef, playKey } = useLabReplay();

  return (
    <div
      ref={rootRef}
      data-lab-island
      className="grid w-full gap-3 p-4 sm:grid-cols-3 lg:p-6"
    >
      <div className={CELL}>
        <p className={CELL_LABEL}>MRR</p>
        <Counter
          to={48230}
          duration={1.6}
          format={(n) => `¥${Math.round(n).toLocaleString()}`}
          playKey={playKey}
        />
        <p className="mt-1 text-xs text-[var(--success)]">+12.4% MoM</p>
      </div>
      <div className={CELL}>
        <p className={CELL_LABEL}>Active users</p>
        <Counter
          to={2348}
          duration={1.6}
          format={(n) => Math.round(n).toLocaleString()}
          playKey={playKey}
        />
        <p className="mt-1 text-xs text-[var(--text-muted)]">last 28 days</p>
      </div>
      <div className={CELL}>
        <p className={CELL_LABEL}>Conversion</p>
        <Counter
          to={3.86}
          duration={1.6}
          format={(n) => `${n.toFixed(2)}%`}
          playKey={playKey}
        />
        <p className="mt-1 text-xs text-[var(--danger)]">−0.3pt WoW</p>
      </div>
    </div>
  );
}