Display

Number Ticker

A count-up number that eases from 0 to its target the first time it scrolls into view — Intl-formatted, prefix/suffix aware.

Preview in your theme

Loading preview…

Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

/** Fast start, soft landing — the classic count-up feel. */
function easeOutExpo(t: number) {
  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)
}

export interface NumberTickerProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Target number to count to. */
  value: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/number-ticker.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "NumberTicker" component (no runtime
dependencies beyond React).

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — `usePrefersReducedMotion()` is the reduced-motion
  read. If rebuilding without it: a useSyncExternalStore over
  matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and a
  server snapshot of false.
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>.
- Props: value: number (target), from = 0 (first-run start), duration = 1500
  (ms), decimals = 0, prefix?: string, suffix?: string, startOnView = true,
  plus className merged via cn(). Spread remaining props on the span.

Behavior
- startOnView true: an IntersectionObserver watches the span; the first
  intersection flips a `started` flag and disconnects the observer — the
  animation fires exactly once and never re-runs on later scrolls.
  startOnView false: start on mount.
- The animation is a requestAnimationFrame loop: progress p = elapsed /
  duration clamped to 1, eased with easeOutExpo (1 - 2^(-10p)); displayed
  value = start + (target - start) * eased. Keep the displayed value in
  state and set it ONLY inside the rAF callback, never synchronously in an
  effect body.
- Mirror the displayed value into a ref. When the `value` prop changes
  (mid-flight or after settling), retarget: animate from the currently
  displayed value to the new target instead of snapping back to `from`.
- prefers-reduced-motion, read via useSyncExternalStore on
  matchMedia("(prefers-reduced-motion: reduce)"): skip the loop entirely and
  render the final value directly.
- Cleanup: cancelAnimationFrame in the effect cleanup, observer.disconnect()
  on unmount. Guard duration <= 0 by jumping straight to the target.

Rendering & styling
- Format with a useMemo'd Intl.NumberFormat("en-US",
  { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) —
  thousands grouping comes for free; render {prefix}{formatted}{suffix}.
- Base classes: relative inline-block tabular-nums; merge consumer
  className via cn(). tabular-nums equalizes digit widths, but the STRING
  still grows during the count ("0" → "12,480") — so reserve the box: an
  invisible sizing span renders the longer of format(from)/format(value)
  (with prefix/suffix) in the layout, and the animating span sits on top
  as absolute inset-0. The element's width never changes mid-count.
- Accessibility: intermediate frames are noise for screen readers — render
  the final formatted value as a third child, <span className="sr-only
  select-none">, and mark both the sizing and animating inner spans
  aria-hidden. Not aria-label on the outer span: a role-less span may not be
  named, and NVDA/JAWS browse mode ignore it. select-none keeps a copied
  selection from carrying the number twice.
- The component ships no colors of its own: it inherits text color and size
  from context, so it drops into any stat card or hero unchanged.

Customization levers
- Easing: swap easeOutExpo for easeOutCubic (softer) or easeOutBack (slight
  overshoot — integers only; overshoot reads wrong with decimals).
- Duration: 1000–2000ms reads well; give larger targets longer durations.
- Compact notation: switch the formatter to { notation: "compact" } to show
  1.2M instead of 1,200,000 — keep decimals at 0–1 in that mode.
- Locale: expose a locale prop passed to Intl.NumberFormat when the host app
  is not en-US; grouping and decimal separators follow automatically.
- Observer eagerness: add threshold / rootMargin to the IntersectionObserver
  to start slightly before the number is fully in view.
- Pairing: drop several instances into a stats-section / KPI grid — the
  ticker inherits typography, so only the grid layout is yours to style.

Concepts

  • Count-up-on-view — the number stays at its starting value until the visitor can actually see it; animating off-screen wastes the moment the effect exists for.
  • Fire-once observer — the IntersectionObserver disconnects after the first hit, so scrolling away and back never replays the entrance; replay is an explicit consumer decision (remount via key).
  • easeOutExpo — exponential deceleration burns through most of the range instantly and lands softly on the target, which is what makes a counter feel "settled" rather than linear and mechanical.
  • Retarget from current — a value change animates from whatever is currently displayed instead of resetting to from, so live prop updates read as adjustment, not restart.
  • setState only in rAF — the display value updates at screen refresh cadence inside the animation frame callback, keeping renders in lockstep with paints and effects free of synchronous state writes.
  • Width reservation — tabular-nums equalizes digits, but the string itself grows while counting; an invisible copy of the widest endpoint holds the box, and the live number animates in an absolute overlay — zero layout shift, even on replay.

On This Page