Count up
StableA number that counts up once, the first time it scrolls into view. Screen readers get the final value straight away.
CountUp renders the final number for screen readers and an animated copy for the eye. The copy waits at zero until 40% of it is visible, then counts on easeOutExpo, with en-US digit grouping. With reduced motion, or before the script runs, it just shows the value; a prerendered number that is already on screen when the page loads stays put.
Updated View source
Headline numbers
import { useState } from 'react'import { CountUp } from '@rchursin/components/motion'import { Button } from '@rchursin/product/ui'import { SampleBadge } from '@rchursin/product/shell/layout'
/** * Each number counts up once, the first time it is 40% visible; a prerendered number that is * already on screen when the page loads keeps its value. Screen readers and reduced-motion * visitors get the final value straight away. Replay remounts the numbers so they count. */const stats = [ { label: 'Sessions read', value: 12480 }, { label: 'Skills never used', value: 37, suffix: '%' }, { label: 'Spend this month', value: 2480, prefix: '$' },]
export default function CountUpStats() { const [run, setRun] = useState(0) return ( <div className="flex w-full max-w-2xl flex-col items-start gap-5"> <div className="flex w-full items-center justify-between gap-3"> <h3 className="text-[15px] font-semibold tracking-[-0.01em] text-fg">Last 30 days</h3> <SampleBadge /> </div> <dl key={run} className="grid w-full gap-3 sm:grid-cols-3"> {stats.map((s) => ( <div key={s.label} className="lux-card flex flex-col-reverse gap-1 rounded-2xl bg-raised p-5"> <dt className="text-[13px] text-fg-3">{s.label}</dt> <dd className="text-[32px]/none font-semibold tracking-[-0.03em] text-fg"> <CountUp value={s.value} prefix={s.prefix} suffix={s.suffix} /> </dd> </div> ))} </dl> <Button variant="secondary" size="sm" icon="rotate-ccw" onClick={() => setRun((n) => n + 1)}> Replay </Button> </div> )}Usage
import { CountUp } from '@rchursin/components/motion'<CountUp value={12480} /><CountUp value={2480} prefix="$" /><CountUp value={37} suffix="%" />Examples
Duration
The same number over 600, 1200 (the default) and 2400 milliseconds.
import { useState } from 'react'import { CountUp } from '@rchursin/components/motion'import { Button } from '@rchursin/product/ui'
/** * duration sets how long the count takes (default 1200ms). Every run eases out the same way * (easeOutExpo), so a longer count spends more time settling on the last digits. */const runs = [600, 1200, 2400]
export default function CountUpDuration() { const [run, setRun] = useState(0) return ( <div className="flex w-full max-w-md flex-col items-start gap-5"> <dl key={run} className="flex w-full flex-col"> {runs.map((ms) => ( <div key={ms} className="flex items-baseline justify-between gap-4 border-b border-line py-3 last:border-0"> <dt className="font-mono text-[12.5px] text-fg-3">duration={`{${ms}}`}</dt> <dd className="text-2xl font-semibold tracking-[-0.02em] text-fg"> <CountUp value={86400} suffix=" s" duration={ms} /> </dd> </div> ))} </dl> <Button variant="secondary" size="sm" icon="rotate-ccw" onClick={() => setRun((n) => n + 1)}> Replay </Button> </div> )}API
Count up props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | – | The final value. Whole numbers only: every frame is rounded, so decimals would end on the wrong figure. |
prefix | string | '' | Text before the number, e.g. "$". |
suffix | string | '' | Text after the number, e.g. "%" or " ms". |
duration | number | 1200 | Milliseconds from zero to the value. |
className | string | – | Classes on the outer <span>, for size and weight. |
Accessibility
- Two copies: an sr-only span with the final, formatted value and an aria-hidden span that counts, so screen readers read one stable number.
- The counting copy uses tabular figures, so its width doesn’t jitter as the digits change.
- Prerendered HTML holds the final value, and hydration keeps it: a number already on screen when the script runs is left alone, and only one below the fold drops to zero to wait. A CountUp mounted later in the browser starts at zero before its first paint, so the final value never flashes first.
- It counts once per mount. A new value or duration counts again from zero; with reduced motion it simply shows the new value.
Guidelines
Do
- Use it for a few headline numbers, not every figure on the page.
- Label invented or sample numbers as sample.
Don’t
- Don’t animate numbers people compare or act on, like prices in a form.
- Don’t pass decimals: it rounds every frame, so 3.2 would land on 3.
- Don’t rely on it for other locales: digits are always grouped the en-US way.
In use
Used on this site
Related
- Scroll arrivalsContent that fades and rises in as it scrolls into view, bars that grow, stamps that land and a frame that draws itself.
- Easing & transitionsFour motion curves, a soft cross-fade for view changes, a reduced-motion hook and disclosures that ease open.
- Dashboard mockA still picture of the product: four headline numbers, a bar list of skill runs and a ledger with verdicts, on sample data.
Source
The component’s own files, exactly as they run on this site.
/** * Small motion helpers for the luxe layer (see styles/luxe.css). No animation library. * Every helper leaves content visible and still when motion is reduced or JS hasn't run. */import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type RefObject,} from "react"import { flushSync } from "react-dom"
const REVEAL_SELECTOR = ".lux-reveal, .lux-frame, .lux-stamp, .lux-grow"
/** true when the visitor allows motion (re-evaluated on change) */export function useMotionOK() { const [ok, setOk] = useState(false) useEffect(() => {