← Easeout Lab
Free component

Number ticker

Counts up once when it scrolls into view, writing to the DOM so React never re-renders per frame.

74

Needspnpm add motion

components/number-ticker.tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { animate, useInView, useReducedMotion } from "motion/react"

// Counts up from 0 the first time it scrolls into view, and again on Replay. The number is written straight
// to the DOM, so React doesn't re-render on every frame. Screen readers get the final value only.
export function NumberTicker({ value = 74 }: { value?: number }) {
  const ref = useRef<HTMLSpanElement>(null)
  const inView = useInView(ref, { once: true, amount: 0.6 })
  const reduce = useReducedMotion()
  const [run, setRun] = useState(0)

  useEffect(() => {
    const el = ref.current
    if (!el || !inView) return
    if (reduce) {
      el.textContent = String(value)
      return
    }
    const controls = animate(0, value, {
      duration: 1.2,
      ease: [0.16, 1, 0.3, 1],
      onUpdate: (v) => (el.textContent = String(Math.round(v))),
    })
    return () => controls.stop()
  }, [inView, reduce, value, run])

  return (
    <div className="flex flex-col items-center gap-4">
      <p className="m-0 text-[64px] leading-none font-semibold tracking-[-0.03em] tabular-nums">
        <span ref={ref} aria-hidden="true">
          0
        </span>
        <span className="sr-only">{value}</span>
      </p>
      {!reduce && (
        <button
          type="button"
          onClick={() => setRun((n) => n + 1)}
          className="h-11 cursor-pointer rounded-full border border-current/20 bg-transparent px-5 text-[14px] font-medium text-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
        >
          Replay
        </button>
      )}
    </div>
  )
}

Want a whole page built like this?