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-upNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { RotateCcwIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { formatNumber } from "@/lib/format"
import { useCountUp } from "@/hooks/use-count-up"
const TARGET = 48219
function Counter() {
const value = useCountUp(TARGET, { duration: 1200 })
return (
<p className="text-3xl font-semibold tabular-nums text-foreground">
{formatNumber(Math.round(value), { maximumFractionDigits: 0 })}
</p>
)
}
export default function UseCountUpDemo() {
// Changing `key` remounts <Counter>, which is the idiomatic way to reset a
// hook's internal state and replay the animation from scratch.
const [run, setRun] = React.useState(0)
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Monthly signups</CardTitle>
<CardDescription>Counts up on mount — replay to watch it again.</CardDescription>
</CardHeader>
<CardContent>
<Counter key={run} />
</CardContent>
<CardFooter className="justify-end">
<Button type="button" variant="outline" size="sm" onClick={() => setRun((r) => r + 1)}>
<RotateCcwIcon />
Replay
</Button>
</CardFooter>
</Card>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| target | number | — | The value to count up to. |
| opts.duration | number | 800 | Animation length in milliseconds. |
| opts.from | number | 0 | Starting value. |
| opts.easing | (t: number) => number | easeOutCubic | Maps elapsed progress (0..1) to eased progress (0..1). |
| opts.enabled | boolean | true | Set false to skip the animation and return target immediately. |
| opts.element | React.RefObject<Element | null> | — | The node that answers for [data-motion="reduced"]; left out, the document does. |
| returns | number | — | The current animated value; equals target immediately when less motion is asked for, either way, or enabled is false. |
Dependencies
Source
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
}