{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "animated-counter", "title": "Animated Counter", "description": "An animated counter component with customizable starting number, target number, speed, and trigger. | カスタマイズ可能な開始数、目標数、速度、トリガーを備えたアニメーションカウンターコンポーネント。", "dependencies": [ "motion" ], "files": [ { "path": "components/text/animated-counter.tsx", "content": "// AnimatedCounter, a component that animates a number from a starting number to a target number.\n\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, animate } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AnimatedCounterTrigger = \"enter\" | \"hover\" | \"whileInView\";\n\nexport default function AnimatedCounter({\n value, // The number to animate to\n from = 0, // The starting number, default is 0\n className, // Optional CSS class names for tailwind\n speed = 0.8, // The speed of the animation, default is 0.8\n trigger = \"enter\", // When to run the animation: \"enter\" = when first entering view, \"whileInView\" = each time entering view, \"hover\" = on hover\n}: {\n value: number;\n from?: number;\n className?: string;\n speed?: number;\n /** When to run the animation: \"enter\" = when first entering view, \"whileInView\" = each time entering view, \"hover\" = on hover */\n trigger?: AnimatedCounterTrigger;\n}) {\n const nodeRef = useRef(null);\n const [shouldAnimate, setShouldAnimate] = useState(false);\n const to = Number(value);\n const useViewport = trigger === \"enter\" || trigger === \"whileInView\";\n\n const runAnimation = useCallback(() => {\n if (!nodeRef.current) return;\n const controls = animate(from, to, {\n duration: speed,\n onUpdate: (current) => {\n if (nodeRef.current) {\n nodeRef.current.textContent = Math.round(current).toString();\n }\n },\n });\n return controls;\n }, [from, to, speed]);\n\n const resetToFrom = useCallback(() => {\n if (nodeRef.current) {\n nodeRef.current.textContent = Math.round(from).toString();\n }\n }, [from]);\n\n // Viewport-based triggers (enter, whileInView)\n useEffect(() => {\n if (!useViewport) return;\n const currentNode = nodeRef.current;\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setShouldAnimate(true);\n } else if (trigger === \"whileInView\") {\n setShouldAnimate(false);\n resetToFrom();\n }\n },\n { threshold: 0.1 },\n );\n\n if (currentNode) observer.observe(currentNode);\n return () => {\n if (currentNode) observer.unobserve(currentNode);\n };\n }, [useViewport, trigger, resetToFrom]);\n\n // Run animation when triggered (viewport modes)\n useEffect(() => {\n if (useViewport && shouldAnimate && nodeRef.current) {\n const controls = runAnimation();\n return () => controls?.stop();\n }\n }, [useViewport, shouldAnimate, runAnimation]);\n\n const handleMouseEnter = useCallback(() => {\n if (trigger === \"hover\") runAnimation();\n }, [trigger, runAnimation]);\n\n const handleMouseLeave = useCallback(() => {\n if (trigger === \"hover\") resetToFrom();\n }, [trigger, resetToFrom]);\n\n // Hover mode: set initial value on mount\n useEffect(() => {\n if (trigger === \"hover\" && nodeRef.current) {\n nodeRef.current.textContent = Math.round(from).toString();\n }\n }, [trigger, from]);\n\n const isVisible = trigger === \"hover\" ? true : shouldAnimate;\n\n return (\n \n );\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }