データ表示
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アニメーションの長さ(ミリ秒)
デフォルト:
1500prefix任意string数値の前に付く文字列(例:¥)
デフォルト:
""suffix任意string数値の後に付く文字列(例:%)
デフォルト:
""decimals任意number表示する小数点以下の桁数
デフォルト:
0className必須string追加のCSSクラス
| 名前 | 型 | デフォルト | 説明 | |
|---|---|---|---|---|
| 必須 | 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>
);
}