d-ui logod-ui
データ表示

Animated Counter

IntersectionObserver を使い、スクロールでビューポートに入ったときにカウントアップアニメーションする数値コンポーネント。

インストール

npx shadcn add https://d-ui.daigo-suhara.com/registry/animated-counter.json

サンプル

0

使い方

import { AnimatedCounter } from "@/components/ui/animated-counter"

export default function Example() {
  return (
    <div className="text-4xl font-bold">
      <AnimatedCounter value={12500} prefix="$" suffix="+" />
    </div>
  )
}

プロパティ

value必須
number

カウントアップする目標値

duration任意
number

アニメーションの長さ(ミリ秒)

デフォルト:1500
prefix任意
string

数値の前に付く文字列(例:¥)

デフォルト:""
suffix任意
string

数値の後に付く文字列(例:%)

デフォルト:""
decimals任意
number

表示する小数点以下の桁数

デフォルト:0
className必須
string

追加のCSSクラス

ソースコード

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

interface AnimatedCounterProps {
  value: number;
  duration?: number;
  prefix?: string;
  suffix?: string;
  decimals?: number;
  className?: string;
}

export function AnimatedCounter({
  value,
  duration = 1500,
  prefix = "",
  suffix = "",
  decimals = 0,
  className,
}: AnimatedCounterProps) {
  const [displayValue, setDisplayValue] = React.useState(0);
  const startTimeRef = React.useRef<number | null>(null);
  const frameRef = React.useRef<number | null>(null);
  const observerRef = React.useRef<IntersectionObserver | null>(null);
  const elementRef = React.useRef<HTMLSpanElement>(null);
  const hasAnimatedRef = React.useRef(false);

  const animate = React.useCallback(
    (timestamp: number) => {
      if (!startTimeRef.current) startTimeRef.current = timestamp;
      const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
      const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
      setDisplayValue(eased * value);

      if (progress < 1) {
        frameRef.current = requestAnimationFrame(animate);
      }
    },
    [duration, value]
  );

  React.useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !hasAnimatedRef.current) {
          hasAnimatedRef.current = true;
          startTimeRef.current = null;
          frameRef.current = requestAnimationFrame(animate);
        }
      },
      { threshold: 0.3 }
    );

    if (elementRef.current) {
      observerRef.current.observe(elementRef.current);
    }

    return () => {
      observerRef.current?.disconnect();
      if (frameRef.current) cancelAnimationFrame(frameRef.current);
    };
  }, [animate]);

  const formatted = displayValue.toFixed(decimals);
  const withCommas = Number(formatted).toLocaleString(undefined, {
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });

  return (
    <span ref={elementRef} className={cn("tabular-nums", className)}>
      {prefix}
      {withCommas}
      {suffix}
    </span>
  );
}