Build a Polished React Animated Counter in React (Hook + Component, A11y-First)

Build an accessible React animated counter with a reusable hook and component—easing, formatting, reduced motion, viewport start, and full control.

ASOasis
9 min read
Build a Polished React Animated Counter in React (Hook + Component, A11y-First)

Image used for representation purposes only.

Overview

Animated counters are those delightful numbers that roll from one value to another—think KPIs ticking up on a dashboard, donation totals, or download counts. In React, it’s tempting to glue a quick setInterval to a state value and call it a day. But a polished counter needs more:

  • Smooth, time‑based animation using requestAnimationFrame
  • Easing curves
  • Precise formatting (decimals, currency, compact notation)
  • Accessibility that won’t overwhelm screen readers
  • Respect for the user’s reduced‑motion preference
  • Controls (start, pause, resume, reset)
  • Sensible behavior with SSR and in React 18 Strict Mode

This article delivers a reusable hook (useCountUp) and a production‑ready Counter component with all of the above.

Design goals

  • Animation quality: time‑based (duration driven) and eased, not frame/step based.
  • Safety: cancel animations on unmount; guard against double‑invocation in Strict Mode.
  • A11y: visible animation is aria-hidden; a live region announces the final value.
  • Performance: minimal renders, only update when the value changes meaningfully.
  • Flexibility: formatting via Intl.NumberFormat; supports decimals; can start on viewport entry.

The animation approach

We’ll animate numerically from from to to over duration milliseconds. On each frame, we compute progress in [0,1], pass it through an easing function, and lerp between the endpoints. requestAnimationFrame keeps things smooth and in sync with the browser’s paint cycle.

A gentle default easing (easeOutCubic) feels responsive: start quickly, ease into the finish.

The hook: useCountUp (TypeScript)

A small, dependency‑free hook that powers the component and is easy to test.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

export type UseCountUpOptions = {
  from?: number;           // default 0
  to: number;              // target value
  duration?: number;       // ms, default 1200
  decimals?: number;       // rounding precision
  easing?: (t: number) => number; // t in [0,1]
  autoplay?: boolean;      // start automatically, default true
  reduceMotion?: boolean | 'media'; // respect reduced motion, default 'media'
  onUpdate?: (value: number) => void;
  onComplete?: () => void;
};

export type UseCountUpApi = {
  value: number;           // animated numeric value
  progress: number;        // [0,1]
  isRunning: boolean;
  start: (opts?: Partial<Pick<UseCountUpOptions,'from'|'to'|'duration'>>) => void;
  pause: () => void;
  resume: () => void;
  reset: (nextFrom?: number, nextTo?: number) => void;
  cancel: () => void;
};

const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);

function usePrefersReducedMotion(): boolean {
  if (typeof window === 'undefined') return false;
  const m = window.matchMedia?.('(prefers-reduced-motion: reduce)');
  return !!m && m.matches;
}

export function useCountUp({
  from = 0,
  to,
  duration = 1200,
  decimals = 0,
  easing = easeOutCubic,
  autoplay = true,
  reduceMotion = 'media',
  onUpdate,
  onComplete,
}: UseCountUpOptions): UseCountUpApi {
  const [value, setValue] = useState<number>(from);
  const [progress, setProgress] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  const frameRef = useRef<number | null>(null);
  const startTimeRef = useRef<number | null>(null);
  const pausedElapsedRef = useRef<number | null>(null);

  const fromRef = useRef<number>(from);
  const toRef = useRef<number>(to);
  const durRef = useRef<number>(duration);
  const easingRef = useRef(easing);
  const decimalsRef = useRef(decimals);

  const prefersReduce = usePrefersReducedMotion();
  const shouldReduce = reduceMotion === true || (reduceMotion === 'media' && prefersReduce);

  // Keep refs in sync without restarting animation
  useEffect(() => { toRef.current = to; }, [to]);
  useEffect(() => { durRef.current = duration; }, [duration]);
  useEffect(() => { easingRef.current = easing; }, [easing]);
  useEffect(() => { decimalsRef.current = decimals; }, [decimals]);

  const cancel = useCallback(() => {
    if (frameRef.current != null) cancelAnimationFrame(frameRef.current);
    frameRef.current = null;
    setIsRunning(false);
  }, []);

  const tick = useCallback((ts: number) => {
    if (startTimeRef.current == null) startTimeRef.current = ts;
    const elapsed = ts - startTimeRef.current;
    const d = Math.max(1, durRef.current);
    const raw = Math.min(1, elapsed / d);
    const eased = easingRef.current(raw);
    const current = fromRef.current + (toRef.current - fromRef.current) * eased;

    const rounded = Number(current.toFixed(decimalsRef.current));
    setValue(rounded);
    setProgress(raw);
    onUpdate?.(rounded);

    if (raw < 1) {
      frameRef.current = requestAnimationFrame(tick);
    } else {
      frameRef.current = null;
      setIsRunning(false);
      onComplete?.();
    }
  }, [onComplete, onUpdate]);

  const start = useCallback((opts?: Partial<Pick<UseCountUpOptions,'from'|'to'|'duration'>>) => {
    cancel();
    fromRef.current = opts?.from ?? value; // default: continue from current display value
    toRef.current = opts?.to ?? toRef.current;
    durRef.current = opts?.duration ?? durRef.current;
    pausedElapsedRef.current = null;
    startTimeRef.current = null;

    if (shouldReduce) {
      // Jump to end in reduced-motion
      setValue(Number(toRef.current.toFixed(decimalsRef.current)));
      setProgress(1);
      onUpdate?.(Number(toRef.current.toFixed(decimalsRef.current)));
      onComplete?.();
      return;
    }

    setIsRunning(true);
    frameRef.current = requestAnimationFrame(tick);
  }, [cancel, onComplete, onUpdate, shouldReduce, tick, value]);

  const pause = useCallback(() => {
    if (!isRunning || frameRef.current == null) return;
    // Store elapsed so we can resume
    if (startTimeRef.current != null) {
      pausedElapsedRef.current = performance.now() - startTimeRef.current;
    }
    cancel();
  }, [cancel, isRunning]);

  const resume = useCallback(() => {
    if (isRunning || pausedElapsedRef.current == null) return;
    setIsRunning(true);
    const resumeAt = performance.now() - pausedElapsedRef.current;
    startTimeRef.current = resumeAt;
    frameRef.current = requestAnimationFrame(tick);
  }, [isRunning, tick]);

  const reset = useCallback((nextFrom?: number, nextTo?: number) => {
    cancel();
    fromRef.current = nextFrom ?? from;
    toRef.current = nextTo ?? toRef.current;
    startTimeRef.current = null;
    pausedElapsedRef.current = null;
    setProgress(0);
    setValue(Number(fromRef.current.toFixed(decimalsRef.current)));
  }, [cancel, from]);

  // Autoplay when the `to` value changes, if enabled
  useEffect(() => {
    if (!autoplay) return;
    // Start from current displayed value to new target
    start({ from: value, to });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [to, autoplay]);

  // Cleanup on unmount or dependency changes
  useEffect(() => cancel, [cancel]);

  return { value, progress, isRunning, start, pause, resume, reset, cancel };
}

Notes:

  • The hook is duration‑driven, so fast devices don’t speed up the animation.
  • It respects reduced motion and completes instantly when enabled.
  • It restarts smoothly when the target changes (autoplay).

The Counter component

A declarative wrapper that formats output, improves a11y, and can start when it enters the viewport.

import React, { useEffect, useMemo } from 'react';
import { useCountUp } from './useCountUp';

type CounterProps = {
  value: number;                 // target value
  from?: number;
  duration?: number;
  decimals?: number;
  easing?: (t: number) => number;
  locale?: string;               // e.g., 'en-US'
  formatOptions?: Intl.NumberFormatOptions; // e.g., { style: 'currency', currency: 'USD' }
  startOnView?: boolean;         // delay start until visible
  className?: string;
};

function useOnScreen<T extends Element>(rootMargin = '0px') {
  const [visible, setVisible] = React.useState(false);
  const ref = React.useRef<T | null>(null);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const obs = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { rootMargin });
    obs.observe(el);
    return () => obs.disconnect();
  }, [rootMargin]);
  return { ref, visible } as const;
}

export function Counter({
  value: target,
  from = 0,
  duration = 1200,
  decimals = 0,
  easing,
  locale,
  formatOptions,
  startOnView = false,
  className,
}: CounterProps) {
  const { ref, visible } = useOnScreen<HTMLSpanElement>('0px 0px -20% 0px');

  const nf = useMemo(() => new Intl.NumberFormat(locale, formatOptions), [locale, formatOptions]);

  const { value, start } = useCountUp({ from, to: target, duration, decimals, easing, autoplay: !startOnView });

  // If startOnView: trigger when visible, and whenever target changes while visible
  useEffect(() => {
    if (!startOnView) return;
    if (visible) start({ to: target });
  }, [visible, target, startOnView, start]);

  const display = useMemo(() => nf.format(value), [value, nf]);
  const finalText = useMemo(() => nf.format(target), [target, nf]);

  return (
    <span ref={ref} className={className}>
      <span aria-hidden="true">{display}</span>
      {/* Screen reader friendly, doesn’t spam during the animation */}
      <span className="sr-only" role="status" aria-live="polite">{finalText}</span>
    </span>
  );
}

Add a reusable screen‑reader utility class once in your CSS:

/* Visually hidden but accessible */
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0);
  white-space: nowrap; border: 0;
}

Usage examples

Basic KPI counter:

export default function KpiCard() {
  return (
    <div className="kpi">
      <h3>Active Users</h3>
      <Counter value={12345} duration={1000} locale="en-US" formatOptions={{ notation: 'compact' }} />
    </div>
  );
}

Currency with two decimals:

<Counter
  value={98765.43}
  decimals={2}
  locale="en-US"
  formatOptions={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }}
/>

Start when visible (e.g., long marketing page):

<section style={{ minHeight: 800 }} />
<section>
  <h2>Trusted by teams worldwide</h2>
  <Counter value={98000} startOnView duration={1500} locale="en-US" formatOptions={{ notation: 'compact' }} />
</section>

Pause/resume with the hook directly:

function ControlledCounter() {
  const api = useCountUp({ from: 0, to: 5000, duration: 2000, autoplay: false });
  return (
    <div>
      <div style={{ fontVariantNumeric: 'tabular-nums' }}>{api.value.toLocaleString()}</div>
      <button onClick={() => api.start({ from: 0, to: 5000 })}>Start</button>
      <button onClick={api.pause}>Pause</button>
      <button onClick={api.resume}>Resume</button>
      <button onClick={() => api.reset(0, 0)}>Reset</button>
    </div>
  );
}

Easing options

Drop‑in alternatives you might like:

const linear = (t: number) => t;
const easeInQuad = (t: number) => t * t;
const easeOutQuad = (t: number) => t * (2 - t);
const easeInOutCubic = (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

Pass any as the easing prop.

Accessibility details

  • The animated number is aria-hidden so screen readers don’t receive dozens of intermediate values.
  • A polite live region announces the final number once.
  • The hook honors prefers‑reduced‑motion to avoid motion for sensitive users.

Tip: If you must announce intermediate milestones (e.g., each integer), throttle updates to one every 250–500 ms to avoid verbosity.

Performance tips

  • Keep decimals low; rounding work compounds across frames.
  • When animating many counters simultaneously, stagger their durations by 50–150 ms to avoid a single heavy frame.
  • Use font-variant-numeric: tabular-nums for stable glyph widths and less layout shift.
  • Avoid React state for non‑visual bookkeeping; refs (as shown) keep re-renders minimal.

SSR and hydration

On the server, render the initial from value. After hydration, the hook animates to the target on the client. Because the component displays the final value only to assistive tech, there’s no noisy mismatch for screen readers.

In Next.js/Remix, this pattern is safe without dynamic imports. If you want to prevent any motion in SSR screenshots, set reduceMotion to true in environments where window is undefined, or conditionally load the component client‑side.

Testing the hook

  • Animate deterministically by mocking performance.now and requestAnimationFrame.
  • Assert onUpdate receives monotonic values and onComplete fires once.
  • Verify reduced‑motion jumps straight to the end state.

Common gotchas and fixes

  • Drifting values: always compute current from t, not by incrementing the last value.
  • Strict Mode double effects: cancelAnimationFrame before starting; the hook’s cancel handles this.
  • Changing targets mid‑flight: start from the current displayed value (default behavior in start()).
  • Countdown: from greater than to works automatically; easing applies the same.

When to use a library instead

If you already use an animation library, you can re-create the counter in:

  • Framer Motion: animate a MotionValue and subscribe its on(“change”) to update text.
  • React Spring: use a spring from { number: from } to { number: to } and interpolate.

These deliver orchestration and gestures, but the hook above is tiny, dependency‑free, and ideal for dashboards.

Wrap up

You now have a compact, production‑grade animated counter for React—complete with a11y, formatting, reduced‑motion support, and precise control. Drop the Counter anywhere you show KPIs, or reach for the hook when you need custom controls. Smooth by default, respectful to users, and easy to maintain.

Related Posts