Mikkel Manniche
Dansk English Deutsch

Motion

Number ticker

Counts up to a number when it scrolls into view. Screen readers hear only the final number.

Preview

Usage

number-ticker-demo.tsx
import { NumberTicker } from '@/components/number-ticker'

export default function NumberTickerDemo() {
  return (
    <p className="font-serif text-6xl tracking-tight">
      <NumberTicker value={1284} locale="da-DK" />
    </p>
  )
}

Source

number-ticker.tsx
import { useEffect, useRef, useState } from 'react'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { cn } from '@/lib/utils'

export type NumberTickerProps = {
  value: number
  /** Where the count starts the first time. */
  from?: number
  /** Milliseconds for one count. */
  duration?: number
  /** Passed to Intl.NumberFormat, e.g. "da-DK". Defaults to the browser's language. */
  locale?: string
  format?: Intl.NumberFormatOptions
  /** Start counting first when the number scrolls into view. */
  startOnView?: boolean
  className?: string
}

const easeOut = (t: number) => 1 - Math.pow(1 - t, 4)

export function NumberTicker({
  value,
  from = 0,
  duration = 1200,
  locale,
  format,
  startOnView = true,
  className,
}: NumberTickerProps) {
  const reduced = useReducedMotion()
  const el = useRef<HTMLSpanElement>(null)
  const shown = useRef(from)
  const [display, setDisplay] = useState(from)
  const [seen, setSeen] = useState(!startOnView)

  useEffect(() => {
    if (seen || !el.current) return
    const io = new IntersectionObserver(([e]) => e.isIntersecting && setSeen(true), { threshold: 0.4 })
    io.observe(el.current)
    return () => io.disconnect()
  }, [seen])

  useEffect(() => {
    // Under reduced motion the final number is shown at once, even before it is in view.
    if (reduced) {
      shown.current = value
      setDisplay(value)
      return
    }
    if (!seen) return
    const start = shown.current
    const t0 = performance.now()
    let frame = 0
    const tick = (now: number) => {
      const t = Math.min(1, (now - t0) / duration)
      const v = start + (value - start) * easeOut(t)
      shown.current = v
      setDisplay(v)
      if (t < 1) frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [value, seen, reduced, duration])

  const fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 0, ...format })

  return (
    <span ref={el} className={cn('tabular-nums', className)}>
      {/* Screen readers get the final number, not every step on the way. */}
      <span aria-hidden>{fmt.format(display)}</span>
      <span className="sr-only">{fmt.format(value)}</span>
    </span>
  )
}
use-reduced-motion.ts
import { useSyncExternalStore } from 'react'

const QUERY = '(prefers-reduced-motion: reduce)'

function subscribe(onChange: () => void) {
  const mq = window.matchMedia(QUERY)
  mq.addEventListener('change', onChange)
  return () => mq.removeEventListener('change', onChange)
}

/** True when the visitor has asked the system for less motion. */
export function useReducedMotion() {
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(QUERY).matches,
    () => false,
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/number-ticker.json

With the registry added (components.json)

npx shadcn@latest add @manniche/number-ticker

Dependencies

Files

  • number-ticker.tsx
  • use-reduced-motion.ts