anima.js
ギャラリー
テキスト

NumberTicker

Motion

LP の実績数値をアニメーションで見せる数字コンポーネント。滑らかなカウントアップと、桁ごとに数字が回る空港の発着案内板風オドメーターの 2 スタイル。スクロールで表示されたときに開始し、値の変更にも追従します。

anima.js / text

数字で見る、これまでの歩み

流通総額
¥12,480,000

サービス開始からの累計

満足度
98.7%

導入企業アンケート

導入社数
2.4万+

2026年10月時点

AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約27 KB)。
# NumberTicker をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: NumberTicker
- 説明: LP の実績数値をアニメーションで見せる数字コンポーネント。滑らかなカウントアップと、桁ごとに数字が回る空港の発着案内板風オドメーターの 2 スタイル。スクロールで表示されたときに開始し、値の変更にも追従します。
- 実行時に必要な npm パッケージ: framer-motion
- 構成ファイル: `components/number-ticker/index.tsx`, `components/number-ticker/NumberTicker.module.css`

## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。

## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install framer-motion
```

### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。

#### `components/number-ticker/index.tsx`
```tsx
"use client";

import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { MotionConfig, animate, motion, type Transition } from "framer-motion";
import styles from "./NumberTicker.module.css";

export type NumberTickerVariant = "count" | "odometer";

export type NumberTickerProps = {
  /** Target number. Changing it re-animates from the number on screen. */
  value: number;
  /** Number the first animation starts from. */
  from?: number;
  /**
   * "count" (default) tweens the whole number up with an ease-out.
   * "odometer" rolls every digit on its own vertical 0–9 strip with
   * staggered springs; separators and units stay still.
   */
  variant?: NumberTickerVariant;
  /** Fraction digits, fixed (always shown, so the width never jitters). */
  decimals?: number;
  /** BCP 47 locale for digits, grouping and compact units. */
  locale?: string;
  /** Static text before the number, e.g. "¥" or "$". */
  prefix?: string;
  /** Static text after the number, e.g. "%" or "+". */
  suffix?: string;
  /** Compact notation: 24000 → "2.4万" (ja-JP) / "24K" (en-US). */
  compact?: boolean;
  /** "count" only: tween length in ms. */
  duration?: number;
  /** "odometer" only: spring stiffness of each digit's roll. */
  springStiffness?: number;
  /** "odometer" only: spring damping — lower overshoots past the digit. */
  springDamping?: number;
  /** Wait (ms) before the first animation. Later re-animations start at once. */
  delay?: number;
  /**
   * true (default): the first animation waits until the number is
   * scrolled into view. false: it starts on mount — for above-the-fold
   * heroes and previews.
   */
  startOnView?: boolean;
  /**
   * Announce value changes to screen readers (aria-live="polite"). Off by
   * default: a stat block that talks on every update is noise.
   */
  announce?: boolean;
  className?: string;
};

const DEFAULT_DURATION = 1800;
const DEFAULT_STIFFNESS = 110;
const DEFAULT_DAMPING = 19;
const DEFAULT_LOCALE = "ja-JP";
/** Left-to-right delay (s) between odometer digits. */
const STAGGER = 0.07;
/** Expo-out: most of the climb happens early, the last digits settle slowly. */
const COUNT_EASE = [0.16, 1, 0.3, 1] as const;

const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";

function subscribeReduced(onChange: () => void) {
  const media = window.matchMedia(REDUCED_QUERY);
  media.addEventListener("change", onChange);
  return () => media.removeEventListener("change", onChange);
}

/** false on the server; follows the OS "reduce motion" setting afterwards. */
function usePrefersReducedMotion() {
  return useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(REDUCED_QUERY).matches,
    () => false,
  );
}

type Formatter = {
  format: (n: number) => string;
  parts: (n: number) => Intl.NumberFormatPart[];
  /** Localized glyph → digit value (handles non-latin numeral systems). */
  digitOf: Map<string, number>;
  /** Digit value → localized glyph, for the odometer strips. */
  glyphs: string[];
};

function createFormatter(
  locale: string,
  decimals: number,
  compact: boolean,
): Formatter {
  const options: Intl.NumberFormatOptions = {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
    notation: compact ? "compact" : "standard",
  };
  let nf: Intl.NumberFormat;
  let digitNf: Intl.NumberFormat;
  try {
    nf = new Intl.NumberFormat(locale, options);
    digitNf = new Intl.NumberFormat(locale, { useGrouping: false });
  } catch {
    // A malformed locale throws RangeError — fall back instead of crashing.
    nf = new Intl.NumberFormat(DEFAULT_LOCALE, options);
    digitNf = new Intl.NumberFormat(DEFAULT_LOCALE, { useGrouping: false });
  }
  const glyphs = Array.from({ length: 10 }, (_, d) => digitNf.format(d));
  return {
    format: (n) => nf.format(n),
    parts: (n) => nf.formatToParts(n),
    digitOf: new Map(glyphs.map((glyph, d) => [glyph, d])),
    glyphs,
  };
}

type Slot =
  | { kind: "digit"; key: string; digit: number; order: number }
  | { kind: "static"; key: string; text: string };

/** Digits of a formatted number, rightmost first. */
function digitsFromRight(fmt: Formatter, n: number): number[] {
  const out: number[] = [];
  for (const char of Array.from(fmt.format(n))) {
    const d = fmt.digitOf.get(char);
    if (d !== undefined) out.push(d);
  }
  return out.reverse();
}

/**
 * Splits the target into digit columns and static text. Columns are keyed
 * by their position from the right, so when the digit count changes the
 * ones digit stays the ones digit and keeps rolling instead of remounting.
 */
function toSlots(fmt: Formatter, n: number): Slot[] {
  const chars: string[] = [];
  for (const part of fmt.parts(n)) chars.push(...Array.from(part.value));
  const digitCount = chars.filter((c) => fmt.digitOf.has(c)).length;
  const slots: Slot[] = [];
  let order = 0;
  let staticRun = "";
  const flush = () => {
    if (!staticRun) return;
    slots.push({ kind: "static", key: `s${slots.length}`, text: staticRun });
    staticRun = "";
  };
  for (const char of chars) {
    const d = fmt.digitOf.get(char);
    if (d === undefined) {
      staticRun += char;
      continue;
    }
    flush();
    slots.push({
      kind: "digit",
      key: `d${digitCount - order - 1}`,
      digit: d,
      order,
    });
    order += 1;
  }
  flush();
  return slots;
}

/**
 * Each strip holds the digits twice (0–9, 0–9). The first run sits at the
 * start digit in the first set and lands in the second set, so every digit
 * spins at least partway round — trailing zeros roll too, airport-board
 * style. Later changes move within the second set: only digits that
 * actually changed roll.
 */
const STRIP_CELLS = 20;
const cellY = (index: number) => `${(-index * 100) / STRIP_CELLS}%`;

type DigitColumnProps = {
  glyphs: string[];
  startIndex: number;
  index: number;
  transition: Transition;
};

function DigitColumn({ glyphs, startIndex, index, transition }: DigitColumnProps) {
  return (
    <span className={styles.column}>
      {/* Sizes the column to the widest digit and gives it a baseline. */}
      <span className={styles.sizer}>{glyphs[8]}</span>
      <motion.span
        className={styles.strip}
        initial={{ y: cellY(startIndex) }}
        animate={{ y: cellY(index) }}
        transition={transition}
      >
        {Array.from({ length: STRIP_CELLS }, (_, i) => (
          <span key={i} className={styles.cell}>
            {glyphs[i % 10]}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

/** Writes into React's own text node, so React's later updates still land. */
function writeText(el: HTMLElement, text: string) {
  const node = el.firstChild;
  if (node && node.nodeType === Node.TEXT_NODE) node.nodeValue = text;
  else el.textContent = text;
}

/**
 * Animated stat number for landing pages. "count" tweens the value up,
 * "odometer" rolls each digit on its own strip. The first run starts when
 * the number scrolls into view; a new `value` re-animates from the number
 * currently on screen. Screen readers get the final formatted value in a
 * visually hidden span; the animated layer is aria-hidden.
 */
export function NumberTicker({
  value,
  from = 0,
  variant = "count",
  decimals = 0,
  locale = DEFAULT_LOCALE,
  prefix = "",
  suffix = "",
  compact = false,
  duration = DEFAULT_DURATION,
  springStiffness = DEFAULT_STIFFNESS,
  springDamping = DEFAULT_DAMPING,
  delay = 0,
  startOnView = true,
  announce = false,
  className,
}: NumberTickerProps) {
  const rootRef = useRef<HTMLSpanElement>(null);
  const countRef = useRef<HTMLSpanElement>(null);
  const reduced = usePrefersReducedMotion();
  const [inView, setInView] = useState(!startOnView);
  // State, not a ref: the odometer reads it during render to apply
  // `delay` to the first run only.
  const [initialValue] = useState(value);
  // "count": the number currently on screen, and the last settled one
  // React renders (per-frame updates bypass React entirely).
  const liveRef = useRef(from);
  const [settled, setSettled] = useState(from);
  const firstRunRef = useRef(true);

  const safeDecimals = Math.max(0, Math.min(20, Math.round(decimals)));
  const fmt = useMemo(
    () => createFormatter(locale, safeDecimals, compact),
    [locale, safeDecimals, compact],
  );

  const active = inView || reduced;

  // First run waits for the viewport. Once seen, it stays "seen".
  useEffect(() => {
    if (inView || reduced) return;
    const el = rootRef.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      (entries) => {
        if (!entries.some((entry) => entry.isIntersecting)) return;
        observer.disconnect();
        setInView(true);
      },
      // Start once the number is properly on screen, not at the very edge.
      { rootMargin: "0px 0px -10% 0px" },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [inView, reduced]);

  // "count": tween from whatever is on screen to `value`.
  useEffect(() => {
    if (variant !== "count" || !active) return;
    const el = countRef.current;
    if (!el) return;
    const render = (n: number) => writeText(el, prefix + fmt.format(n) + suffix);
    if (reduced) {
      liveRef.current = value;
      render(value);
      return;
    }
    const wait = firstRunRef.current ? delay / 1000 : 0;
    const controls = animate(liveRef.current, value, {
      duration: Math.max(0, duration) / 1000,
      delay: wait,
      ease: COUNT_EASE,
      onUpdate: (n) => {
        // Cleared on the first real frame (after the delay), so a StrictMode
        // re-run or an early cancel doesn't swallow the delay.
        firstRunRef.current = false;
        liveRef.current = n;
        render(n);
      },
      onComplete: () => setSettled(value),
    });
    return () => controls.stop();
  }, [variant, active, reduced, value, duration, delay, fmt, prefix, suffix]);

  const finalText = prefix + fmt.format(value) + suffix;

  const classes = [styles.root, className].filter(Boolean).join(" ");

  let visual;
  if (variant === "odometer") {
    const startDigits = digitsFromRight(fmt, from);
    const first = value === initialValue;
    const slots = toSlots(fmt, value);
    const digitTotal = slots.filter((s) => s.kind === "digit").length;
    visual = (
      <span className={styles.odometer}>
        {prefix ? <span className={styles.affix}>{prefix}</span> : null}
        {slots.map((slot) => {
          if (slot.kind === "static") {
            return (
              <span key={slot.key} className={styles.affix}>
                {slot.text}
              </span>
            );
          }
          const fromRight = digitTotal - slot.order - 1;
          const startIndex = reduced ? 10 + slot.digit : (startDigits[fromRight] ?? 0);
          return (
            <DigitColumn
              key={slot.key}
              glyphs={fmt.glyphs}
              startIndex={startIndex}
              index={active ? 10 + slot.digit : startIndex}
              transition={{
                type: "spring",
                stiffness: springStiffness,
                damping: springDamping,
                mass: 1,
                delay: (first ? delay / 1000 : 0) + slot.order * STAGGER,
              }}
            />
          );
        })}
        {suffix ? <span className={styles.affix}>{suffix}</span> : null}
      </span>
    );
  } else {
    const shown = reduced ? value : settled;
    visual = (
      // The hidden ghost reserves the final width from the first frame, so
      // the box doesn't grow as "0" climbs to "12,480,000".
      <span className={styles.stack}>
        <span className={styles.ghost}>{finalText}</span>
        <span ref={countRef} className={styles.count}>
          {prefix + fmt.format(shown) + suffix}
        </span>
      </span>
    );
  }

  return (
    // Follows the OS "reduce motion" setting: digits land without rolling.
    <MotionConfig reducedMotion="user">
      <span
        ref={rootRef}
        className={classes}
        data-number-ticker
        data-variant={variant}
      >
        <span className={styles.srOnly} aria-live={announce ? "polite" : undefined}>
          {finalText}
        </span>
        <span className={styles.visual} aria-hidden>
          {visual}
        </span>
      </span>
    </MotionConfig>
  );
}

export default NumberTicker;
```

#### `components/number-ticker/NumberTicker.module.css`
```css
/* ==========================================================================
   Number ticker

   Font family, size, weight and color are inherited from the caller; the
   component only fixes the numeric features that keep the width stable.
   - "count":    one text node, rewritten per frame, over a hidden ghost of
                 the final text that reserves the width.
   - "odometer": per digit, a clipped column with a 20-cell strip (0–9 ×2)
                 that framer-motion translates; separators are static.
   ========================================================================== */

.root {
  /* Height of one odometer cell. A little taller than the glyphs so the
     edge fade never touches a resting digit. */
  --nt-cell-h: 1.2em;
  /* How far the top/bottom fade of a rolling column reaches. */
  --nt-fade: 14%;

  display: inline-block;
  white-space: nowrap;
  /* Equal-width digits: a column never resizes as its digit changes. */
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

/* The accessible copy of the value: read by screen readers, never seen. */
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.visual {
  display: inline-block;
}

/* --- count ---------------------------------------------------------------- */

.stack {
  display: inline-grid;
  /* Numbers grow leftward, so the last digit stays put while counting. */
  justify-items: end;
}

.stack > * {
  grid-area: 1 / 1;
}

.ghost {
  visibility: hidden;
}

/* --- odometer ------------------------------------------------------------- */

.odometer {
  display: inline-flex;
  /* Not baseline: an overflow-clipped inline-block's baseline is its
     bottom edge. Every slot instead shares one line box height, so the
     glyphs line up exactly. */
  align-items: flex-start;
  line-height: var(--nt-cell-h);
}

.affix {
  height: var(--nt-cell-h);
  white-space: pre;
}

.column {
  position: relative;
  display: inline-block;
  height: var(--nt-cell-h);
  line-height: var(--nt-cell-h);
  overflow: hidden;
  /* Soft edges: neighbouring digits blur in and out instead of being cut
     by a hard line while the strip rolls. */
  mask-image: linear-gradient(
    to bottom,
    transparent,
    #000 var(--nt-fade),
    #000 calc(100% - var(--nt-fade)),
    transparent
  );
}

/* In-flow, invisible: gives the column its width. */
.sizer {
  display: block;
  visibility: hidden;
}

.strip {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  will-change: transform;
}

.cell {
  display: block;
  height: var(--nt-cell-h);
  line-height: var(--nt-cell-h);
}

/* framer-motion already lands the digits instantly under reduced motion;
   this also drops the fade so a resting digit reads as plain text. */
@media (prefers-reduced-motion: reduce) {
  .column {
    mask-image: none;
  }

  .strip {
    will-change: auto;
  }
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/number-ticker.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { NumberTicker } from "@/components/number-ticker";

// TODO: 文字サイズ・太さ・色は親要素か className で指定してください(コンポーネントはフォントを継承します)
// TODO: ファーストビューに置く場合は startOnView={false} を渡すと、スクロールを待たずにマウント直後から動きます
<NumberTicker
  value={12480000}
  from={0}
  prefix="¥"
  suffix=""
  decimals={0}
  compact={false}
  variant="odometer"
  duration={1800}
  springStiffness={110}
  springDamping={19}
  locale="ja-JP"
/>
```

### 4. 組み込み手順
1. 数値を見せたい場所に `<NumberTicker value={12480000} prefix="¥" variant="odometer" />` を置く。コンポーネント自体が `"use client"` なので、Server Component からそのまま使える。
2. 文字サイズ・太さ・色・フォントは親要素か `className` で指定する(すべて継承)。等幅数字(`tabular-nums`)はコンポーネント側で有効にしているので追加不要。
3. 既定ではスクロールで画面内に入ったときに 1 回だけ開始する。ファーストビューに置く場合は `startOnView={false}` でマウント直後から動かす。
4. `value` を変えると、いま表示している数値から新しい値へアニメーションし直す(API の取得結果やリアルタイム更新にそのまま使える)。`delay` は最初の 1 回だけに効く。
5. 書式は `Intl.NumberFormat`。`locale`(既定 `"ja-JP"`)・`decimals`・`compact`(24000 → 2.4万 / 24K)を指定する。通貨記号や「%」「+」は `prefix` / `suffix` に渡す。
6. 値の更新を読み上げさせたい場合(ダッシュボードなど)だけ `announce` を付ける。LP の実績数値では付けない。
7. `npm run build` が通ることを確認し、「スクロールで開始する」「桁数が増えても横幅が揺れない」「OS の視差効果を減らす設定で最終値が即表示される」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| カウント中に数字の幅が揺れて周囲がガタつく | 等幅数字が切れている、または最終値のゴーストを消した | `font-variant-numeric: tabular-nums` と、最終値を `visibility: hidden` で重ねる `.ghost` を残す |
| アニメーション終了後や再レンダー時に数字が古い値に戻る | 毎フレーム `el.textContent = …` で書き換え、React のテキストノードを差し替えてしまった | 同梱の `writeText()`(React のテキストノードの `nodeValue` だけを書き換える)を使う。毎フレーム `setState` もしない |
| オドメーターの桁が区切り文字と上下にずれる | 桁の列は `overflow: hidden` の inline-block なので、ベースラインが下端になる | `align-items: baseline` に戻さない。全スロットを同じ `line-height` / 高さ(`--nt-cell-h`)で `flex-start` 揃えにする |
| 値を変えると下の桁まで最初から回り直す | 桁の `key` を左からの位置にしている | key は**右からの位置**。桁数が増減しても一の位は一の位のまま回り続ける |
| 視差効果オフでも数字が回る | framer-motion は JS で動くので CSS の reduced-motion が効かない | `<MotionConfig reducedMotion="user">` と `usePrefersReducedMotion` の分岐を外さない |
| 短縮表記のカウント中に「9,999.0」が一瞬出る | ja-JP の compact は 1万未満を省略しない | `from` を 10000 以上にする(デモの導入社数カードと同じ) |

### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)
- ファイル: `components/number-ticker/NumberTicker.tsx`(`"use client"`)+ `NumberTicker.module.css` + `index.ts`
- Props: `value`(必須)、`from = 0`、`variant: "count" | "odometer" = "count"`、`decimals = 0`、`locale = "ja-JP"`、`prefix`、`suffix`、`compact = false`、`duration = 1800`(ms・count のみ)、`springStiffness = 110` / `springDamping = 19`(odometer のみ)、`delay = 0`(ms・初回のみ)、`startOnView = true`、`announce = false`、`className`
- 書式: `new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, notation: compact ? "compact" : "standard" })`。不正な locale は `RangeError` になるので try/catch で `"ja-JP"` にフォールバック

### 見た目
- フォント・サイズ・太さ・色はすべて継承。ルートは `display: inline-block; white-space: nowrap; font-variant-numeric: tabular-nums`
- **count**: `inline-grid` の同じセルに「最終値(`visibility: hidden` のゴースト)」と「表示中の値」を重ね、`justify-items: end`。最初のフレームから最終値の幅が確保され、一の位の位置が動かない
- **odometer**: `formatToParts` の結果を 1 文字ずつ見て、数字は「桁の列」、それ以外(区切り・小数点・「万」・マイナス)は静的なテキストにする。prefix / suffix も静的
  - 桁の列: `height: 1.2em`(`--nt-cell-h`)、`overflow: hidden`、上下 14% をマスクでフェード(`--nt-fade`)。幅は不可視の「8」で確保
  - 中に 0–9 を 2 周(20 セル)並べた縦ストリップを `position: absolute` で置き、`translateY(-index × 5%)` で目的のセルを見せる
  - 全スロットを同じ高さ・`line-height` で `align-items: flex-start` に揃える(`overflow: hidden` の inline-block はベースラインが下端になるため baseline 揃えは不可)
  - 数字のグリフは `Intl.NumberFormat(locale, { useGrouping: false }).format(0〜9)` から作る(アラビア数字以外の数字体系にも対応)

### モーション
- **count**: framer-motion の `animate(現在の値, value, { duration, delay, ease: [0.16, 1, 0.3, 1] })`。`onUpdate` で React のテキストノードの `nodeValue` を直接書き換え(毎フレーム setState しない)、完了時に 1 回だけ state を更新
- **odometer**: 各桁の `motion.span` に `initial={{ y: 開始セル }}` / `animate={{ y: 目的セル }}`、`type: "spring", stiffness, damping, mass: 1`、遅延は `delay + 左からの桁番号 × 70ms`
  - 初回: 開始セル = `from` の同じ位(右揃え)の数字(1 周目)、目的セル = 10 + 目標の数字(2 周目)。末尾の 0 も含めてすべての桁が回る
  - 値の変更時: 2 周目の中で移動するので、変わった桁だけが回る。桁の key は右からの位置
- 開始: `IntersectionObserver`(`rootMargin: "0px 0px -10% 0px"`)で画面に入ったら 1 回だけ。`startOnView={false}` ならマウント直後
- `value` の変更: 表示中の数値(count は ref に保持した現在値、odometer は各ストリップの現在位置)から再アニメーション
- `prefers-reduced-motion: reduce`: 最終値を即表示(`usePrefersReducedMotion` で分岐し、`<MotionConfig reducedMotion="user">` でも包む)

### アクセシビリティ
- 最終値(prefix + 書式済みの値 + suffix)を visually hidden の span に常に置く。アニメーションする層は `aria-hidden`
- `aria-live` は既定で付けない。`announce` のときだけ visually hidden の span に `aria-live="polite"`
- SSR では `from` の値が描画される(visually hidden の最終値はサーバーでも出力されるので、検索エンジンと支援技術は最終値を読める)

### 受け入れ条件
- スクロールして画面に入ると、カウントアップ / 桁の回転が始まる。1 度だけ
- 桁数が増えても、カウント中に横幅が揺れない。オドメーターの区切り文字と数字の高さが揃う
- `value` を変えると、表示中の数値から新しい値へ動き直す
- スクリーンリーダーは最終値だけを読み、途中の数字を読まない
- 視差効果を減らす設定では最終値がすぐに表示される
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/number-ticker.json
生成コード
import { NumberTicker } from "@/components/number-ticker";

// TODO: 文字サイズ・太さ・色は親要素か className で指定してください(コンポーネントはフォントを継承します)
// TODO: ファーストビューに置く場合は startOnView={false} を渡すと、スクロールを待たずにマウント直後から動きます
<NumberTicker
  value={12480000}
  from={0}
  prefix="¥"
  suffix=""
  decimals={0}
  compact={false}
  variant="odometer"
  duration={1800}
  springStiffness={110}
  springDamping={19}
  locale="ja-JP"
/>