Number ticker
Counts up to a value when it scrolls in, formatted for the language with Intl and set in tabular digits. The final width is reserved from the start.
One of 5 components in Text accents
Details
What Number ticker brings along, read from the registry and the source.
- Category
- Text accents
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/number-ticker.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The NumberTickerProps interface from components/ui/number-ticker.tsx, with the comments from the code.
valuerequirednumberThe value to arrive at
fromoptionalnumberWhere counting starts. Default 0.
variantoptionalNumberTickerVariantcountroll
count: the number counts up (default). roll: every digit rolls like an odometer.
formatoptionalIntl.NumberFormatOptionsIntl.NumberFormat options, e.g. { style: 'currency', currency: 'EUR' } or { style: 'percent' }
localeoptionalstringA locale for Intl, e.g. 'de-CH'. Default: the kit locale (de: de-DE, en: en-US).
durationoptionalnumberMilliseconds the count takes. Default 1800.
delayoptionalnumberMilliseconds before it starts. Default 0.
triggeroptional'load' | 'view' | 'in-view' | 'scroll'load: right away. view (or in-view): when it scrolls in (default). scroll: the number follows the scroll position, it counts up on the way into the window and back down when you scroll up.
scrollRangeoptional[number, number]With trigger scroll: the part of the way through the window that counts, 0 to 1. Default [0.05, 0.5].
asoptionalTagspanpdivstrongdd
Element to render. Default span.
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 240 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useLocale } from '@/lib/kit';
export type NumberTickerVariant = 'count' | 'roll';
type Tag = 'span' | 'p' | 'div' | 'strong' | 'dd';
export interface NumberTickerProps {
/** The value to arrive at */
value: number;
/** Where counting starts. Default 0. */
from?: number;
/** count: the number counts up (default). roll: every digit rolls like an odometer. */
variant?: NumberTickerVariant;
/** Intl.NumberFormat options, e.g. { style: 'currency', currency: 'EUR' } or { style: 'percent' } */
format?: Intl.NumberFormatOptions;
/** A locale for Intl, e.g. 'de-CH'. Default: the kit locale (de: de-DE, en: en-US). */
locale?: string;
/** Milliseconds the count takes. Default 1800. */
duration?: number;
/** Milliseconds before it starts. Default 0. */
delay?: number;
/**
* load: right away. view (or in-view): when it scrolls in (default).
* scroll: the number follows the scroll position, it counts up on the
* way into the window and back down when you scroll up.
*/
trigger?: 'load' | 'view' | 'in-view' | 'scroll';
/** With trigger scroll: the part of the way through the window that counts, 0 to 1. Default [0.05, 0.5]. */
scrollRange?: [number, number];
/** Element to render. Default span. */
as?: Tag;
className?: string;
}
/*
* The final number is always in the box, invisible, so the box has its
* final width from the first paint and the counting number sits on top,
* aligned to the end like an odometer. Digits are tabular.
* count: hidden until hydration (only with scripting and motion), so the
* final value does not flash before counting from zero.
* roll: pure CSS, every digit column rolls from 0 to its digit.
*/
const CSS = `
.kitfx-nt{display:inline-grid;font-variant-numeric:tabular-nums;white-space:nowrap}
.kitfx-nt>*{grid-area:1/1}
.kitfx-nt-ghost{visibility:hidden}
.kitfx-nt-live{justify-self:end}
.kitfx-nt-col{display:inline-block;position:relative;clip-path:inset(0 -.1em)}
.kitfx-nt-col>.kitfx-nt-strip{position:absolute;left:0;right:0;top:0;display:flex;flex-direction:column;transform:translateY(calc(var(--n) * -1lh));animation:kitfx-nt-roll var(--kitfx-nt-dur) cubic-bezier(.3,.6,.15,1) calc(var(--kitfx-nt-delay) + var(--k) * 70ms) both}
.kitfx-nt-strip>span{height:1lh;text-align:center}
.kitfx-nt-col>.kitfx-nt-digit{visibility:hidden}
.kitfx-nt[data-mode=wait] .kitfx-nt-strip{animation:none;transform:translateY(0)}
.kitfx-nt[data-mode=scroll] .kitfx-nt-strip{animation:none;transform:translateY(calc(var(--n) * var(--kitfx-p,1) * -1lh));transition:transform .25s linear}
@keyframes kitfx-nt-roll{from{transform:translateY(0)}}
@keyframes kitfx-nt-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitfx-nt[data-pending] .kitfx-nt-live{animation:kitfx-nt-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitfx-nt-col>.kitfx-nt-strip{animation:none;transition:none}}`;
const LOCALES = { en: 'en-US', de: 'de-DE' } as const;
/** Decimals of a number as written, so 3.8 counts as 0.0 … 3.8 and not 3.81234 */
const decimalsOf = (n: number) => {
const s = String(n);
const i = s.indexOf('.');
return i < 0 ? 0 : Math.min(s.length - i - 1, 4);
};
const easeOut = (t: number) => 1 - Math.pow(1 - t, 4);
/**
* Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
* travelled `start` of its way through the window, 1 at `end` (0 is its top
* at the bottom edge, 1 its bottom at the top edge). The value is scaled to
* the part of that way the page can still scroll, so a line at the end of a
* short page still finishes. Never with reduced motion, then the CSS
* fallback 1 keeps the final state.
*/
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
const [start, end] = range;
const listener = useRef(onChange);
listener.current = onChange;
useEffect(() => {
const el = ref.current;
if (!el || !on || prefersReducedMotion()) return;
let frame = 0;
const span = Math.max(0.0001, end - start);
const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
const most = at(r.top - rest, r.height, vh);
const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
el.style.setProperty('--kitfx-p', p.toFixed(4));
listener.current?.(p);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
el.style.removeProperty('--kitfx-p');
};
}, [ref, on, start, end]);
}
/**
* Counts up to a value when it scrolls into view, formatted for the locale
* with Intl (48.210 € in German, $48,210 in English). Screen readers get the
* final value once. Without JavaScript and with reduced motion the final
* value stands there.
* <NumberTicker value={48210} format={{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }} className="text-h1" />
*/
export function NumberTicker({ value, from = 0, variant = 'count', format, locale, duration = 1800, delay = 0, trigger = 'view', scrollRange = [0.05, 0.5], as: Tag = 'span', className }: NumberTickerProps) {
const kitLocale = useLocale();
const loc = locale ?? LOCALES[kitLocale];
const digits = decimalsOf(value);
const opts: Intl.NumberFormatOptions = {
...(format?.style === 'percent' ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
...format,
};
const fmt = new Intl.NumberFormat(loc, opts);
const final = fmt.format(value);
const ref = useRef<HTMLElement>(null);
const [current, setCurrent] = useState(value);
const [pending, setPending] = useState(variant === 'count');
const scroll = trigger === 'scroll';
const [mode, setMode] = useState<'play' | 'wait' | 'scroll'>(scroll ? 'scroll' : 'play');
// Scroll: the count eases out over the way, so the last digits settle slowly
useScrollLink(ref, scroll, scrollRange, (p) => {
if (variant === 'count') setCurrent(p >= 1 ? value : from + (value - from) * p * (2 - p));
});
useEffect(() => {
setPending(false);
const el = ref.current;
if (!el || scroll || prefersReducedMotion()) return;
const below = el.getBoundingClientRect().top >= window.innerHeight * 0.9;
if (variant === 'roll') {
// CSS rolls from the first paint. Further down, hold it until it scrolls in.
if (trigger === 'load' || !below) return;
setMode('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setMode('play');
io.disconnect();
},
{ rootMargin: '0px 0px -10% 0px' },
);
io.observe(el);
return () => io.disconnect();
}
let raf = 0;
let timer = 0;
setCurrent(from);
const run = () => {
const start = performance.now();
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
setCurrent(t >= 1 ? value : from + (value - from) * easeOut(t));
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
};
const go = () => {
timer = window.setTimeout(run, delay);
};
if (trigger === 'load' || !below) {
go();
return () => {
window.clearTimeout(timer);
cancelAnimationFrame(raf);
};
}
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
io.disconnect();
go();
},
{ rootMargin: '0px 0px -10% 0px' },
);
io.observe(el);
return () => {
io.disconnect();
window.clearTimeout(timer);
cancelAnimationFrame(raf);
};
}, [value, from, duration, delay, trigger, variant, scroll]);
const vars = { '--kitfx-nt-dur': `${duration}ms`, '--kitfx-nt-delay': `${delay}ms` } as React.CSSProperties;
let live: React.ReactNode;
if (variant === 'roll') {
const chars = Array.from(final);
let k = 0;
live = chars.map((c, i) => {
if (!/\d/.test(c)) return <span key={i}>{c}</span>;
// Every column turns at least once, the ones to the right start a little later
return (
<span key={i} className="kitfx-nt-col" style={{ '--n': 10 + Number(c), '--k': k++ } as React.CSSProperties}>
<span className="kitfx-nt-digit">{c}</span>
<span className="kitfx-nt-strip">
{Array.from({ length: 20 }, (_, j) => (
<span key={j}>{j % 10}</span>
))}
</span>
</span>
);
});
} else {
live = current === value ? final : fmt.format(current);
}
return (
<Tag ref={ref as never} className={cn('kitfx-nt', className)} data-pending={pending ? '' : undefined} data-mode={mode} style={vars}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<span className="sr-only">{final}</span>
<span aria-hidden className="kitfx-nt-ghost">
{final}
</span>
<span aria-hidden className="kitfx-nt-live">
{live}
</span>
</Tag>
);
}Frequent questions
How do I install Number ticker?
With aiw add @ai-website/number-ticker. The command puts components/ui/number-ticker.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Number ticker have?
3 variants, Stats, Odometer and In a sentence, on scroll.
Can an AI assistant build with Number ticker?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Number ticker with aiw_components and install it with aiw_add @ai-website/number-ticker. Name the variant in your prompt, for example variant="roll", and the assistant writes your content straight into the props.