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.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/number-ticker