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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

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

function subscribeReducedMotion(callback: () => void) {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
  mq.addEventListener("change", callback)
  return () => mq.removeEventListener("change", callback)

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
- 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 — put the
  final formatted value in aria-label on the outer span and mark both the
  sizing and animating inner spans aria-hidden.
- 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 reservationtabular-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