Skip to contentVibraUI
Foundation

useCountUp

Animates a number from a starting value up to a target using requestAnimationFrame.

Returns target immediately, with no animation, when enabled is false or less motion is asked for — by the reader's OS setting or by [data-motion="reduced"] on an ancestor, the kit's own switch, which it reads through useReducedMotion. Pass element, a ref to the node the number sits in, so a switch set on a frame's own wrapper stops it too; without one the document answers.

Install

npx shadcn@latest add @vibra/use-count-up

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
targetnumber—The value to count up to.
opts.durationnumber800Animation length in milliseconds.
opts.fromnumber0Starting value.
opts.easing(t: number) => numbereaseOutCubicMaps elapsed progress (0..1) to eased progress (0..1).
opts.enabledbooleantrueSet false to skip the animation and return target immediately.
opts.elementReact.RefObject<Element | null>—The node that answers for [data-motion="reduced"]; left out, the document does.
returnsnumber—The current animated value; equals target immediately when less motion is asked for, either way, or enabled is false.

Dependencies

Source

hooks/use-count-up.ts
import * as React from "react"
import { useMediaQuery } from "@/hooks/use-media-query"
import { prefersReducedMotion, useReducedMotion } from "@/hooks/use-reduced-motion"

/** Cubic ease-out: fast start, slow finish. */
function easeOutCubic(t: number): number {
  return 1 - Math.pow(1 - t, 3)
}

/**
 * Animates a number from `from` (default 0) to `target` over `duration` ms
 * (default 800) via requestAnimationFrame; returns `target` immediately when
 * less motion is asked for or `enabled` is false.
 *
 * Less motion is asked for two ways, and both stop the count: the reader's OS
 * setting, and `[data-motion="reduced"]` on an ancestor — the kit's own switch,
 * which a page or a preview frame sets. The hook read only the first, so a
 * BigNumber inside a frame that had asked for less motion still counted up.
 * Pass `element` to read the switch where the number sits; without it the
 * document answers.
 */
export function useCountUp(
  target: number,
  opts?: {
    duration?: number
    from?: number
    easing?: (t: number) => number
    enabled?: boolean
    /** The node that answers for `[data-motion="reduced"]`; defaults to the document. */
    element?: React.RefObject<Element | null>
  }
): number {
  const { duration = 800, from = 0, easing = easeOutCubic, enabled = true, element } = opts ?? {}
  // The media query answers on the first client render; the kit's switch, a
  // token on an ancestor, is read by the first effect — the same pass that
  // would schedule the first frame, so the count is called off before it
  // ever ticks.
  const osReduced = useMediaQuery("(prefers-reduced-motion: reduce)")
  const kitReduced = useReducedMotion(element)
  const animate = enabled && !osReduced && !kitReduced

  const [value, setValue] = React.useState(animate ? from : target)

  React.useEffect(() => {
    if (!animate) return
    // The kit's switch lands in state a render after this effect runs, and a
    // frame queued meanwhile could paint one number on the way up. Asked
    // directly, the node answers now: queue nothing, and the render the switch
    // brings returns the target.
    if (prefersReducedMotion(element?.current)) return
    let frameId = 0
    let start: number | null = null

    const tick = (timestamp: number) => {
      if (start === null) start = timestamp
      const elapsed = timestamp - start
      const progress = duration <= 0 ? 1 : Math.min(elapsed / duration, 1)
      setValue(from + (target - from) * easing(progress))
      if (progress < 1) frameId = requestAnimationFrame(tick)
    }

    frameId = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frameId)
  }, [animate, target, duration, from, easing, element])

  return animate ? value : target
}