78 / Motion
Number Ticker
Counts to a number rather than replacing it, in whatever currency or format you asked for.
- Installs
- Uptime
- Monthly
"use client"
import * as React from "react"
import { NumberTicker } from "mischief-ui/number-ticker"
export function NumberTickerDemo() {
const [multiplier, setMultiplier] = React.useState(1)
return (
<div className="grid w-full max-w-md gap-6">
<dl className="grid grid-cols-3 gap-3 text-center">
<div className="border-border bg-card rounded-[var(--radius)] border p-4">
<dd className="text-2xl font-semibold">
<NumberTicker value={12480 * multiplier} startOnView={false} />
</dd>
<dt className="text-muted-foreground mt-1 text-xs">Installs</dt>
</div>
<div className="border-border bg-card rounded-[var(--radius)] border p-4">
<dd className="text-2xl font-semibold">
<NumberTicker
value={0.982}
startOnView={false}
format={{ style: "percent", maximumFractionDigits: 1 }}
/>
</dd>
<dt className="text-muted-foreground mt-1 text-xs">Uptime</dt>
</div>
<div className="border-border bg-card rounded-[var(--radius)] border p-4">
<dd className="text-2xl font-semibold">
<NumberTicker
value={1499 * multiplier}
startOnView={false}
format={{ style: "currency", currency: "USD" }}
/>
</dd>
<dt className="text-muted-foreground mt-1 text-xs">Monthly</dt>
</div>
</dl>
<button
type="button"
className="border-border mx-auto inline-flex min-h-11 items-center rounded-full border px-4 text-sm font-semibold"
onClick={() => setMultiplier((value) => (value === 1 ? 3 : 1))}
>
Change the numbers
</button>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/number-tickerimport { NumberTicker } from "mischief-ui/number-ticker"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type NumberTickerProps = Omit< React.HTMLAttributes<HTMLSpanElement>, "children"> & { value: number /** Where it counts from the first time. Defaults to zero. */ from?: number duration?: number /** Passed straight to Intl.NumberFormat, so currency and percent work. */Usage
export function Stat({ revenue }) {
return (
<NumberTicker
value={revenue}
format={{ style: "currency", currency: "USD" }}
/>
)
}What gets read out
The element is labelled with the final value the whole time, and the counting digits are hidden. A reader using a screen reader is told the number, once, rather than being read a blur of intermediate values or catching whatever it happened to be passing through.
It also means the number is correct before the animation starts and correct if it never starts, which is what happens under reduced motion: the value is set straight away.
Counting from wherever it was
When the value changes again the count starts from what was on screen, not from the original starting point. A figure that updates while someone is looking at it moves from the old number to the new one, which is the only reading of it that means anything.
The digits are tabular, so the width does not jump about while it counts.
API
valuenumberWhere it is counting to.fromnumberWhere it counts from the first time. Defaults to 0.durationnumberMilliseconds. Defaults to 1400.formatIntl.NumberFormatOptionsPassed straight through, so currency, percent, and compact all work.localestringPassed to Intl.NumberFormat.startOnViewbooleanWaits until it is on screen before counting. On by default.Accessibility
The final value is the element's label from the first paint, and the animating digits are hidden. Reduced motion sets the number immediately. Nothing here is a live region, so a page of these does not interrupt anybody.