Number Animation
Animated number primitives for count-up values, rolling tickers, and fixed-slot digit swaps.
Digit Swap
digit-swap.tsxFixed-slot digits and mask glyphs that roll on change with controllable direction, stagger, replay, and suffix emphasis.
"use client";
import { useState } from "react";
import { DigitSwap } from "@/components/motion/digit-swap";
const CARD_NUMBER = "4242 4242 4242 0806";
const MASKED_NUMBER = "•••• •••• •••• 0806";
export function DigitSwapPreview() {
const [revealed, setRevealed] = useState(false);
return (
<div className="flex w-80 flex-col gap-5">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
Card number
</span>
<DigitSwap
value={revealed ? CARD_NUMBER : MASKED_NUMBER}
animationKey={revealed ? "revealed" : "masked"}
direction={revealed ? "up" : "down"}
suffixLength={4}
glyphClassName={
revealed ? "text-foreground" : "text-muted-foreground"
}
suffixClassName="text-foreground"
className="font-mono text-lg tracking-[0.08em] tabular-nums"
/>
</div>
<button
type="button"
aria-label={revealed ? "Animate masked number" : "Animate card number"}
aria-pressed={revealed}
onClick={() => setRevealed((current) => !current)}
className="h-10 self-start rounded-lg border border-border px-3 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
Animate
</button>
</div>
);
}
"use client";
// beui.dev/components/motion/number
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DigitSwapDirection = "up" | "down";
export interface DigitSwapProps {
/** Numeric or masked value rendered in fixed character slots. */
value: string | number;
/** Replays every glyph when the value itself contains unchanged characters. */
animationKey?: string | number;
/** Direction the next glyph enters from. */
direction?: DigitSwapDirection;
/** Per-glyph transition duration in seconds. */
duration?: number;
/** Delay in seconds between neighboring glyphs. */
stagger?: number;
/** Number of final characters that receive `suffixClassName`. */
suffixLength?: number;
className?: string;
glyphClassName?: string;
suffixClassName?: string;
}
type GlyphMotionContext = {
direction: DigitSwapDirection;
reduceMotion: boolean;
};
const GLYPH_VARIANTS = {
enter: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "45%" : "-45%"})`,
}),
visible: {
opacity: 1,
transform: "translateY(0%)",
},
exit: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "-45%" : "45%"})`,
}),
};
/** Fixed-slot digits and mask glyphs that roll when their value changes. */
export function DigitSwap({
value,
animationKey,
direction = "up",
duration = 0.18,
stagger = 0.006,
suffixLength = 0,
className,
glyphClassName,
suffixClassName,
}: DigitSwapProps) {
const reduceMotion = useReducedMotion() ?? false;
const text = String(value);
const suffixStart = Math.max(0, text.length - Math.max(0, suffixLength));
const motionContext: GlyphMotionContext = { direction, reduceMotion };
const glyphs = Array.from(text, (character, position) => ({
character,
id: `glyph-${position}`,
position,
}));
return (
<span
data-slot="digit-swap"
data-direction={direction}
className={cn("inline-flex items-center whitespace-nowrap", className)}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true" className="inline-flex items-center">
{glyphs.map(({ character, id, position }) => {
if (character === " ") {
return <span key={id} className="inline-block w-[0.7ch]" />;
}
const glyphKey =
animationKey === undefined
? `${id}-${character}`
: `${id}-${character}-${animationKey}`;
return (
<span
key={id}
data-slot="digit-swap-glyph"
className="relative inline-block h-[1.1em] w-[1ch] shrink-0 overflow-hidden align-bottom"
>
<AnimatePresence initial={false} custom={motionContext}>
<motion.span
key={glyphKey}
custom={motionContext}
variants={GLYPH_VARIANTS}
initial="enter"
animate="visible"
exit="exit"
transition={{
duration: reduceMotion ? Math.min(duration, 0.12) : duration,
delay: reduceMotion ? 0 : position * stagger,
ease: EASE_OUT,
}}
className={cn(
"absolute inset-0 flex items-center justify-center leading-none",
glyphClassName,
position >= suffixStart ? suffixClassName : undefined,
)}
>
{character}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/number
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DigitSwapDirection = "up" | "down";
export interface DigitSwapProps {
/** Numeric or masked value rendered in fixed character slots. */
value: string | number;
/** Replays every glyph when the value itself contains unchanged characters. */
animationKey?: string | number;
/** Direction the next glyph enters from. */
direction?: DigitSwapDirection;
/** Per-glyph transition duration in seconds. */
duration?: number;
/** Delay in seconds between neighboring glyphs. */
stagger?: number;
/** Number of final characters that receive `suffixClassName`. */
suffixLength?: number;
className?: string;
glyphClassName?: string;
suffixClassName?: string;
}
type GlyphMotionContext = {
direction: DigitSwapDirection;
reduceMotion: boolean;
};
const GLYPH_VARIANTS = {
enter: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "45%" : "-45%"})`,
}),
visible: {
opacity: 1,
transform: "translateY(0%)",
},
exit: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "-45%" : "45%"})`,
}),
};
/** Fixed-slot digits and mask glyphs that roll when their value changes. */
export function DigitSwap({
value,
animationKey,
direction = "up",
duration = 0.18,
stagger = 0.006,
suffixLength = 0,
className,
glyphClassName,
suffixClassName,
}: DigitSwapProps) {
const reduceMotion = useReducedMotion() ?? false;
const text = String(value);
const suffixStart = Math.max(0, text.length - Math.max(0, suffixLength));
const motionContext: GlyphMotionContext = { direction, reduceMotion };
const glyphs = Array.from(text, (character, position) => ({
character,
id: `glyph-${position}`,
position,
}));
return (
<span
data-slot="digit-swap"
data-direction={direction}
className={cn("inline-flex items-center whitespace-nowrap", className)}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true" className="inline-flex items-center">
{glyphs.map(({ character, id, position }) => {
if (character === " ") {
return <span key={id} className="inline-block w-[0.7ch]" />;
}
const glyphKey =
animationKey === undefined
? `${id}-${character}`
: `${id}-${character}-${animationKey}`;
return (
<span
key={id}
data-slot="digit-swap-glyph"
className="relative inline-block h-[1.1em] w-[1ch] shrink-0 overflow-hidden align-bottom"
>
<AnimatePresence initial={false} custom={motionContext}>
<motion.span
key={glyphKey}
custom={motionContext}
variants={GLYPH_VARIANTS}
initial="enter"
animate="visible"
exit="exit"
transition={{
duration: reduceMotion ? Math.min(duration, 0.12) : duration,
delay: reduceMotion ? 0 : position * stagger,
ease: EASE_OUT,
}}
className={cn(
"absolute inset-0 flex items-center justify-center leading-none",
glyphClassName,
position >= suffixStart ? suffixClassName : undefined,
)}
>
{character}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}
API Reference
valuestring | numberNumeric or masked value rendered in fixed character slots.
—animationKey?string | numberReplays every glyph when the value itself contains unchanged characters.
—direction?"up" | "down"Direction the next glyph enters from.
upduration?numberPer-glyph transition duration in seconds.
0.18stagger?numberDelay in seconds between neighboring glyphs.
0.006suffixLength?numberNumber of final characters that receive `suffixClassName`.
0className?string—glyphClassName?string—suffixClassName?string—Number Ticker
number-ticker.tsxSlot-machine rolling digits with staggered entry.
Active users
48,27301234567890123456789,012345678901234567890123456789live · updates every 2.5s
"use client";
import { useEffect, useState } from "react";
import { NumberTicker } from "@/components/motion/number-ticker";
export function NumberTickerPreview() {
const [value, setValue] = useState(48273);
useEffect(() => {
const id = setInterval(() => setValue((v) => v + Math.floor(Math.random() * 50)), 2500);
return () => clearInterval(id);
}, []);
return (
<div className="flex flex-col items-center gap-3">
<p className="text-xs text-muted-foreground">Active users</p>
<NumberTicker
value={value}
prefix=""
className="text-4xl font-semibold tracking-tight text-foreground tabular-nums"
format={(n) => n.toLocaleString()}
/>
<p className="text-xs text-muted-foreground">live · updates every 2.5s</p>
</div>
);
}
"use client";
// beui.dev/components/motion/number
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NumberTickerProps {
value: number;
/** Digits to pad to (left). */
pad?: number;
/** Per-digit roll duration in seconds. */
duration?: number;
/** Stagger between digits. */
stagger?: number;
/** Render only after the element enters the viewport. */
startOnView?: boolean;
prefix?: string;
suffix?: string;
/** Add a small blur during digit rolls. */
blur?: boolean;
className?: string;
digitClassName?: string;
/** Insert locale group separators (commas). Server-component safe. */
locale?: boolean;
/** Custom formatter. Client-only — server components must use `locale` instead. */
format?: (value: number) => string;
}
const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);
export function NumberTicker({
value,
pad,
duration = 0.9,
stagger = 0.04,
startOnView = true,
prefix,
suffix,
blur = false,
className,
digitClassName,
locale,
format,
}: NumberTickerProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const inView = useInView(containerRef, { once: true, amount: 0.6 });
const [armed, setArmed] = useState(!startOnView);
useEffect(() => {
if (startOnView && inView) setArmed(true);
}, [startOnView, inView]);
const text = useMemo(() => {
const rounded = Math.round(value);
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString()
: rounded.toString();
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, format, locale]);
const glyphs = useMemo(() => {
const chars = text.split("");
// Key by place value (position from the right): a changing digit keeps its
// identity and rolls to the new value instead of remounting and replaying
// from 0. Growing numbers add glyphs on the left without re-keying the
// ones, tens, hundreds already on screen.
return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
}, [text]);
const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;
// Stagger is an entrance flourish. Once the reveal has played, value
// changes roll every digit immediately — a per-digit delay on live updates
// reads as lag.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!armed || entered) return;
const total = (duration + glyphs.length * stagger) * 1000;
const t = window.setTimeout(() => setEntered(true), total);
return () => window.clearTimeout(t);
}, [armed, entered, duration, stagger, glyphs.length]);
return (
<span
ref={containerRef}
className={cn("inline-flex items-center tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-center">
{prefix ? <span>{prefix}</span> : null}
{glyphs.map(({ char, id }, i) => {
const isDigit = /\d/.test(char);
if (!isDigit) {
return (
<span key={id} className="inline-block">
{char}
</span>
);
}
const digit = Number(char);
return (
<Digit
key={id}
digit={armed ? digit : 0}
delay={entered ? 0 : i * stagger}
duration={duration}
blur={blur}
className={digitClassName}
/>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
function Digit({
digit,
delay,
duration,
blur,
className,
}: {
digit: number;
delay: number;
duration: number;
blur: boolean;
className?: string;
}) {
const reduce = useReducedMotion();
const columnRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
return;
}
const node = columnRef.current;
const controls = animate(
node,
{ filter: ["blur(10px)", "blur(0px)"] },
{
duration: Math.min(duration * 0.75, 0.32),
delay,
ease: EASE_OUT,
},
);
return () => {
controls.stop();
node.style.filter = "blur(0px)";
};
}, [blur, delay, digit, duration, reduce]);
return (
<span
className={cn("relative inline-block overflow-hidden", className)}
style={{ height: `${DIGIT_HEIGHT_EM}em`, width: "1ch" }}
>
<motion.span
ref={columnRef}
initial={{ y: 0 }}
animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
transition={
reduce
? { duration: 0 }
: { duration, delay, ease: EASE_OUT }
}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
>
{DIGITS.map((n) => (
<span
key={n}
className="flex h-[1.1em] items-center justify-center leading-none"
>
{n}
</span>
))}
</motion.span>
</span>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/number
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NumberTickerProps {
value: number;
/** Digits to pad to (left). */
pad?: number;
/** Per-digit roll duration in seconds. */
duration?: number;
/** Stagger between digits. */
stagger?: number;
/** Render only after the element enters the viewport. */
startOnView?: boolean;
prefix?: string;
suffix?: string;
/** Add a small blur during digit rolls. */
blur?: boolean;
className?: string;
digitClassName?: string;
/** Insert locale group separators (commas). Server-component safe. */
locale?: boolean;
/** Custom formatter. Client-only — server components must use `locale` instead. */
format?: (value: number) => string;
}
const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);
export function NumberTicker({
value,
pad,
duration = 0.9,
stagger = 0.04,
startOnView = true,
prefix,
suffix,
blur = false,
className,
digitClassName,
locale,
format,
}: NumberTickerProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const inView = useInView(containerRef, { once: true, amount: 0.6 });
const [armed, setArmed] = useState(!startOnView);
useEffect(() => {
if (startOnView && inView) setArmed(true);
}, [startOnView, inView]);
const text = useMemo(() => {
const rounded = Math.round(value);
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString()
: rounded.toString();
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, format, locale]);
const glyphs = useMemo(() => {
const chars = text.split("");
// Key by place value (position from the right): a changing digit keeps its
// identity and rolls to the new value instead of remounting and replaying
// from 0. Growing numbers add glyphs on the left without re-keying the
// ones, tens, hundreds already on screen.
return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
}, [text]);
const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;
// Stagger is an entrance flourish. Once the reveal has played, value
// changes roll every digit immediately — a per-digit delay on live updates
// reads as lag.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!armed || entered) return;
const total = (duration + glyphs.length * stagger) * 1000;
const t = window.setTimeout(() => setEntered(true), total);
return () => window.clearTimeout(t);
}, [armed, entered, duration, stagger, glyphs.length]);
return (
<span
ref={containerRef}
className={cn("inline-flex items-center tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-center">
{prefix ? <span>{prefix}</span> : null}
{glyphs.map(({ char, id }, i) => {
const isDigit = /\d/.test(char);
if (!isDigit) {
return (
<span key={id} className="inline-block">
{char}
</span>
);
}
const digit = Number(char);
return (
<Digit
key={id}
digit={armed ? digit : 0}
delay={entered ? 0 : i * stagger}
duration={duration}
blur={blur}
className={digitClassName}
/>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
function Digit({
digit,
delay,
duration,
blur,
className,
}: {
digit: number;
delay: number;
duration: number;
blur: boolean;
className?: string;
}) {
const reduce = useReducedMotion();
const columnRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
return;
}
const node = columnRef.current;
const controls = animate(
node,
{ filter: ["blur(10px)", "blur(0px)"] },
{
duration: Math.min(duration * 0.75, 0.32),
delay,
ease: EASE_OUT,
},
);
return () => {
controls.stop();
node.style.filter = "blur(0px)";
};
}, [blur, delay, digit, duration, reduce]);
return (
<span
className={cn("relative inline-block overflow-hidden", className)}
style={{ height: `${DIGIT_HEIGHT_EM}em`, width: "1ch" }}
>
<motion.span
ref={columnRef}
initial={{ y: 0 }}
animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
transition={
reduce
? { duration: 0 }
: { duration, delay, ease: EASE_OUT }
}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
>
{DIGITS.map((n) => (
<span
key={n}
className="flex h-[1.1em] items-center justify-center leading-none"
>
{n}
</span>
))}
</motion.span>
</span>
);
}
API Reference
valuenumber—pad?numberDigits to pad to (left).
—duration?numberPer-digit roll duration in seconds.
0.9stagger?numberStagger between digits.
0.04startOnView?booleanRender only after the element enters the viewport.
trueprefix?string—suffix?string—blur?booleanAdd a small blur during digit rolls.
falseclassName?string—digitClassName?string—locale?booleanInsert locale group separators (commas). Server-component safe.
—format?((value: number) => string)Custom formatter. Client-only — server components must use `locale` instead.
—Animated Number
animated-number.tsxSpring-driven count-up triggered when in view.
Monthly recurring revenue
+12.4% vs last month
"use client";
import { AnimatedNumber } from "@/components/motion/animated-number";
export function AnimatedNumberPreview() {
return (
<div className="flex flex-col items-center gap-3">
<p className="text-xs text-muted-foreground">Monthly recurring revenue</p>
<div className="text-4xl font-semibold tracking-tight text-foreground tabular-nums">
<AnimatedNumber value={129480} format={(n) => `$${Math.round(n).toLocaleString()}`} />
</div>
<p className="text-xs text-(--color-success)">+12.4% vs last month</p>
</div>
);
}
"use client";
// beui.dev/components/motion/number
import { animate, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AnimatedNumberProps {
value: number;
duration?: number;
format?: (n: number) => string;
className?: string;
startOnView?: boolean;
}
export function AnimatedNumber({
value,
duration = 1.2,
format = (n) => Math.round(n).toLocaleString(),
className,
startOnView = true,
}: AnimatedNumberProps) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
if (startOnView && !inView) return;
if (reduce) {
fromRef.current = value;
setDisplay(value);
return;
}
const controls = animate(fromRef.current, value, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setDisplay(v),
});
fromRef.current = value;
return () => controls.stop();
}, [value, duration, inView, startOnView, reduce]);
return (
<span ref={ref} className={cn("tabular-nums", className)}>
{format(display)}
</span>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/number
import { animate, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AnimatedNumberProps {
value: number;
duration?: number;
format?: (n: number) => string;
className?: string;
startOnView?: boolean;
}
export function AnimatedNumber({
value,
duration = 1.2,
format = (n) => Math.round(n).toLocaleString(),
className,
startOnView = true,
}: AnimatedNumberProps) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
if (startOnView && !inView) return;
if (reduce) {
fromRef.current = value;
setDisplay(value);
return;
}
const controls = animate(fromRef.current, value, {
duration,
ease: EASE_OUT,
onUpdate: (v) => setDisplay(v),
});
fromRef.current = value;
return () => controls.stop();
}, [value, duration, inView, startOnView, reduce]);
return (
<span ref={ref} className={cn("tabular-nums", className)}>
{format(display)}
</span>
);
}
API Reference
valuenumber—duration?number1.2format?((n: number) => string)(n) => Math.round(n).toLocaleString()className?string—startOnView?booleantrueRelated components
Action Swap
CTA button and slot primitives for swapping text and icons with blur motion.
Text Animation
Animated text primitives for spring reveals, chromatic sweeps, shimmer loading states, letter-cascade swaps and character scrambles.
Loader
Loading indicator with seventeen variants: spinner, dots, bars, dot-matrix, dither, morph, comet, scramble, metaballs, newton, helix, percent, and five terminal-style ascii spinners. Scales from one size prop, uses currentColor, and reduced-motion swaps every transform for a calm opacity pulse.
Updated