# Number Ticker

> Number that counts up to its value once it scrolls into view.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/number-ticker
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/number-ticker.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/number-ticker-demo.tsx"
import { NumberTicker } from "@/components/number-ticker"

const stats = [
  { value: 12480, label: "Developers", suffix: "+" },
  { value: 99.98, label: "Uptime", suffix: "%", decimals: 2 },
  { value: 3.2, label: "Raised", prefix: "$", suffix: "M", decimals: 1 },
]

export default function NumberTickerDemo() {
  return (
    <dl className="grid w-full max-w-2xl grid-cols-3 divide-x rounded-2xl border bg-card">
      {stats.map(({ label, ...stat }) => (
        <div key={label} className="flex flex-col items-center gap-1 px-4 py-8">
          <dd className="text-3xl font-semibold tracking-tight sm:text-4xl">
            <NumberTicker {...stat} />
          </dd>
          <dt className="text-sm text-muted-foreground">{label}</dt>
        </div>
      ))}
    </dl>
  )
}
```

## Source

```tsx title="components/number-ticker.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface NumberTickerProps extends React.ComponentProps<"span"> {
  value: number
  /** Number the count starts from */
  from?: number
  /** Seconds the count takes */
  duration?: number
  decimals?: number
  prefix?: string
  suffix?: string
  /** BCP 47 locale for digit grouping */
  locale?: string
}

const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - 2 ** (-10 * t))

// Counts up once the number scrolls into view. With reduced motion the final
// value is shown right away.
export function NumberTicker({
  value,
  from = 0,
  duration = 2,
  decimals = 0,
  prefix = "",
  suffix = "",
  locale = "en-US",
  className,
  ...props
}: NumberTickerProps) {
  const ref = React.useRef<HTMLSpanElement>(null)
  const formatter = React.useMemo(
    () => new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),
    [locale, decimals]
  )
  const format = React.useCallback((n: number) => `${prefix}${formatter.format(n)}${suffix}`, [formatter, prefix, suffix])

  React.useEffect(() => {
    const el = ref.current
    if (!el) return
    // Write into React's own text node, so later renders still find it
    const show = (n: number) => {
      if (el.firstChild) el.firstChild.nodeValue = format(n)
    }
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      show(value)
      return
    }
    let frame = 0
    const observer = new IntersectionObserver(([entry]) => {
      if (!entry.isIntersecting) return
      observer.disconnect()
      const start = performance.now()
      const tick = (now: number) => {
        const t = Math.min(1, (now - start) / (duration * 1000))
        show(from + (value - from) * easeOutExpo(t))
        if (t < 1) frame = requestAnimationFrame(tick)
      }
      frame = requestAnimationFrame(tick)
    })
    observer.observe(el)
    return () => {
      observer.disconnect()
      cancelAnimationFrame(frame)
    }
  }, [value, from, duration, format])

  return (
    <span ref={ref} className={cn("inline-block tabular-nums", className)} {...props}>
      {format(from)}
    </span>
  )
}
```
