Counts from a start value to a target with an expo-out ease when scrolled into view, with fixed decimals, tabular numerals, and optional prefix and suffix.
Preview
Customize
6 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add number-tickerWrites components/ui/number-ticker.tsx and components/styles/number-ticker.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { NumberTicker } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <NumberTicker />6 );7}Props
Accessibility
The root exposes the final formatted value (with prefix and suffix) via role="img" and aria-label, so assistive tech never reads intermediate numbers. The server-rendered text is the final value; under prefers-reduced-motion it stays final with no count animation.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { animate, useInView, useReducedMotion } from "motion/react";5import styles from "../styles/number-ticker.module.css";6 7export interface NumberTickerProps {8 /** Target value to count up to */9 value?: number;10 /** Starting value of the count */11 from?: number;12 /** Count duration in seconds */13 duration?: number;14 /** Number of fraction digits to display */15 decimals?: number;16 /** Text rendered before the number */17 prefix?: string;18 /** Text rendered after the number */19 suffix?: string;20 /** Optional className */21 className?: string;22}23 24function format(n: number, decimals: number) {25 return n.toLocaleString("en-US", {26 minimumFractionDigits: decimals,27 maximumFractionDigits: decimals,28 });29}30 31export function NumberTicker({32 value = 12480,33 from = 0,34 duration = 1.6,35 decimals = 0,36 prefix = "",37 suffix = "+",38 className = "",39}: NumberTickerProps) {40 const rootRef = React.useRef<HTMLSpanElement>(null);41 const numRef = React.useRef<HTMLSpanElement>(null);42 const inView = useInView(rootRef, { once: true, margin: "0px 0px -10% 0px" });43 const prefersReducedMotion = useReducedMotion();44 const finalText = format(value, decimals);45 46 React.useEffect(() => {47 const node = numRef.current;48 if (!node) return;49 if (prefersReducedMotion) {50 node.textContent = finalText;51 return;52 }53 if (!inView) {54 node.textContent = format(from, decimals);55 return;56 }57 const controls = animate(from, value, {58 duration,59 ease: [0.16, 1, 0.3, 1],60 onUpdate: (v) => {61 node.textContent = format(v, decimals);62 },63 });64 return () => controls.stop();65 }, [inView, value, from, duration, decimals, prefersReducedMotion, finalText]);66 67 return (68 <span69 ref={rootRef}70 role="img"71 aria-label={`${prefix}${finalText}${suffix}`}72 className={`${styles.root} ${className}`.trim()}73 >74 {prefix ? (75 <span aria-hidden="true" className={styles.affix}>76 {prefix}77 </span>78 ) : null}79 <span ref={numRef} aria-hidden="true" className={styles.number}>80 {finalText}81 </span>82 {suffix ? (83 <span aria-hidden="true" className={styles.affix}>84 {suffix}85 </span>86 ) : null}87 </span>88 );89}