Number Ticker
Number that rolls to a new value like an odometer, one changed digit at a time.
1,284
"use client";
import { useState } from "react";
import { Button } from "@/components/motion/button";
import { NumberTicker } from "@/components/motion/number-ticker";
export function NumberTickerPreview() {
const [value, setValue] = useState(1284);
return (
<div className="flex flex-col items-center gap-4">
<div className="font-display text-5xl font-semibold text-foreground">
<NumberTicker value={value} />
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setValue((v) => Math.max(0, v - 137))}>
Sell
</Button>
<Button variant="secondary" size="sm" onClick={() => setValue((v) => v + 349)}>
Buy
</Button>
</div>
</div>
);
}
"use client";
// easeui.dev/components/motion/number-ticker
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const EASE = [0.23, 1, 0.32, 1] as const;
export interface NumberTickerProps {
/** The number to display. Changing it rolls each digit to its new value. */
value: number;
/** Formats the number before it is split into characters. Default: grouped with commas. */
format?: (value: number) => string;
className?: string;
}
const defaultFormat = (value: number) => Math.round(value).toLocaleString("en-US");
/**
* A number that rolls to a new value like an odometer: each character that
* changes slides up and out while its replacement slides up into place.
* Characters keep their slot counting from the right, so a comma or a new
* leading digit never disturbs the ones already on screen.
*/
export function NumberTicker({ value, format = defaultFormat, className }: NumberTickerProps) {
const reduce = useReducedMotion();
const text = format(value);
const characters = text.split("");
return (
<span className={cn("inline-flex tabular-nums", className)}>
<span aria-hidden="true" className="inline-flex">
{characters.map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: the key is distance from the right edge, stable as digits are added.
key={characters.length - index}
className="relative inline-block overflow-hidden"
>
<span className="invisible">{char}</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={char}
initial={reduce ? false : { y: "70%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={reduce ? undefined : { y: "-70%", opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.35, ease: EASE }}
className="absolute inset-0"
>
{char}
</motion.span>
</AnimatePresence>
</span>
))}
</span>
<span className="sr-only">{text}</span>
</span>
);
}
Installation
$ bunx --bun shadcn add @easeui/number-ticker
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
components/motion/number-ticker.tsx "use client"; // easeui.dev/components/motion/number-ticker import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; const EASE = [0.23, 1, 0.32, 1] as const; export interface NumberTickerProps { /** The number to display. Changing it rolls each digit to its new value. */ value: number; /** Formats the number before it is split into characters. Default: grouped with commas. */ format?: (value: number) => string; className?: string; } const defaultFormat = (value: number) => Math.round(value).toLocaleString("en-US"); /** * A number that rolls to a new value like an odometer: each character that * changes slides up and out while its replacement slides up into place. * Characters keep their slot counting from the right, so a comma or a new * leading digit never disturbs the ones already on screen. */ export function NumberTicker({ value, format = defaultFormat, className }: NumberTickerProps) { const reduce = useReducedMotion(); const text = format(value); const characters = text.split(""); return ( <span className={cn("inline-flex tabular-nums", className)}> <span aria-hidden="true" className="inline-flex"> {characters.map((char, index) => ( <span // biome-ignore lint/suspicious/noArrayIndexKey: the key is distance from the right edge, stable as digits are added. key={characters.length - index} className="relative inline-block overflow-hidden" > <span className="invisible">{char}</span> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={char} initial={reduce ? false : { y: "70%", opacity: 0 }} animate={{ y: "0%", opacity: 1 }} exit={reduce ? undefined : { y: "-70%", opacity: 0 }} transition={{ duration: reduce ? 0 : 0.35, ease: EASE }} className="absolute inset-0" > {char} </motion.span> </AnimatePresence> </span> ))} </span> <span className="sr-only">{text}</span> </span> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/button.tsx "use client"; import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react"; import { forwardRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ButtonSize = "sm" | "md" | "lg" | "icon"; export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children"> { /** Visual style. Default "primary". */ variant?: ButtonVariant; /** Height and padding. "icon" is a square button for a single icon. Default "md". */ size?: ButtonSize; children?: ReactNode; } // A quick press confirms the tap before the action finishes. const PRESS = { scale: 0.97 }; const NO_PRESS = { scale: 1 }; const PRESS_TRANSITION = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const; // Hairline rings are drawn with box-shadow so they blend with any background. const VARIANT_CLASS: Record<ButtonVariant, string> = { primary: "bg-foreground text-background hover:bg-foreground/90", secondary: "bg-card text-foreground shadow-[0_0_0_1px_var(--border)] hover:bg-muted", outline: "text-foreground shadow-[0_0_0_1px_var(--border-strong)] hover:bg-foreground/5", ghost: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground", }; // Small sizes grow an invisible hit area so the tap target stays around 44px. const SIZE_CLASS: Record<ButtonSize, string> = { sm: "h-8 gap-1.5 px-3 text-xs after:absolute after:-inset-1.5", md: "h-10 gap-2 px-4 text-sm", lg: "h-12 gap-2 px-5 text-base", icon: "h-9 w-9 after:absolute after:-inset-1", }; export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = "primary", size = "md", type = "button", className, children, ...props }, ref, ) { const reduce = useReducedMotion(); return ( <motion.button ref={ref} type={type} // Always pass a gesture so the server and client render the same attributes. whileTap={reduce ? NO_PRESS : PRESS} transition={PRESS_TRANSITION} className={cn( "group relative inline-flex shrink-0 touch-manipulation select-none items-center justify-center rounded-full font-medium outline-none will-change-transform", "transition-[background-color,color,box-shadow] duration-150 ease-out", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50", VARIANT_CLASS[variant], SIZE_CLASS[size], className, )} {...props} > {children} </motion.button> ); });
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | - | The number to display. Changing it rolls each digit to its new value. |
| format? | ((value: number) => string) | (value: number) => Math.round(value).toLocaleString("en-US") | Formats the number before it is split into characters. Default: grouped with commas. |
| className? | string | - | - |
Updated