Browse the library
Components / Data
Animated counter
FreeNumber that counts up when scrolled into view, with prefix, suffix, decimals and Intl formatting.
View as Markdownmotion
components/facade/animated-counter.tsx
"use client";
import * as React from "react";
import { animate, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface AnimatedCounterProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/** Target value. */
value: number;
/** Starting value. Defaults to 0. */
from?: number;
/** Text before the number, e.g. "$". */
prefix?: string;
/** Text after the number, e.g. "+", "M", "%". */
suffix?: string;
/** Fixed number of decimals. Defaults to 0. */
decimals?: number;
/** BCP 47 locale for Intl.NumberFormat. Defaults to "en-US". */
locale?: string;
/** Extra Intl.NumberFormat options (merged after decimals). */
formatOptions?: Intl.NumberFormatOptions;
/** Animation duration in seconds. Defaults to 1.8. */
duration?: number;
/** Delay in seconds once in view. Defaults to 0. */
delay?: number;
/** Animate only the first time it enters the viewport. Defaults to true. */
once?: boolean;
className?: string;
}
export function AnimatedCounter({
value,
from = 0,
prefix = "",
suffix = "",
decimals = 0,
locale = "en-US",
formatOptions,
duration = 1.8,
delay = 0,
once = true,
className,
"aria-label": ariaLabel,
...rest
}: AnimatedCounterProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once, margin: "0px 0px -10% 0px" });
const reduce = useReducedMotion();
// Compare `formatOptions` by value so an inline object does not restart the animation on every parent render.
const formatOptionsKey = JSON.stringify(formatOptions ?? null);
const formatter = React.useMemo(() => {
const extra = JSON.parse(formatOptionsKey) as Intl.NumberFormatOptions | null;
return new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
...extra,
});
}, [locale, decimals, formatOptionsKey]);
const format = React.useCallback((n: number) => `${prefix}${formatter.format(n)}${suffix}`, [formatter, prefix, suffix]);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
if (!inView) {
if (!once) node.textContent = format(from);
return;
}
if (reduce) {
node.textContent = format(value);
return;
}
const controls = animate(from, value, {
duration,
delay,
ease: [0.16, 1, 0.3, 1],
onUpdate: (v) => {
node.textContent = format(v);
},
});
return () => controls.stop();
}, [inView, from, value, duration, delay, reduce, once, format]);
return (
<span {...rest} className={cn("tabular-nums", className)}>
{/* Screen readers get the final value; the animated text is decorative. */}
<span className="sr-only">{ariaLabel ?? format(value)}</span>
<span ref={ref} aria-hidden>
{format(from)}
</span>
</span>
);
}
export default AnimatedCounter;app/page.tsx
import { Clock, Home, Star, TrendingUp } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { AnimatedCounter } from "@/components/facade/animated-counter";
interface Stat {
icon: LucideIcon;
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
label: string;
note: string;
}
const stats: Stat[] = [
{ icon: TrendingUp, value: 2.4, prefix: "$", suffix: "M", decimals: 1, label: "Saved for homeowners", note: "Since 2019" },
{ icon: Home, value: 1240, suffix: "+", label: "Homes serviced", note: "Across Austin metro" },
{ icon: Star, value: 4.9, decimals: 1, label: "Average rating", note: "386 Google reviews" },
{ icon: Clock, value: 45, suffix: " min", label: "Avg. response time", note: "7 days a week" },
];
export default function Demo() {
return (
<section className="w-full max-w-3xl">
<div className="mb-6 flex items-end justify-between gap-6">
<div>
<p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">Lone Star Plumbing Co.</p>
<h3 className="mt-2 font-display text-2xl font-semibold tracking-tight sm:text-3xl">Numbers our neighbors trust</h3>
</div>
</div>
<div className="grid grid-cols-2 overflow-hidden rounded-2xl border border-border bg-border gap-px lg:grid-cols-4">
{stats.map((s) => (
<div key={s.label} className="flex flex-col bg-card p-5 text-card-foreground sm:p-6">
<span className="inline-flex size-8 items-center justify-center rounded-lg bg-brand/10 text-brand">
<s.icon className="size-4" />
</span>
<AnimatedCounter
value={s.value}
prefix={s.prefix}
suffix={s.suffix}
decimals={s.decimals}
className="mt-5 font-display text-3xl font-semibold tracking-tight sm:text-4xl"
/>
<span className="mt-1.5 text-sm font-medium">{s.label}</span>
<span className="mt-0.5 text-xs text-muted-foreground">{s.note}</span>
</div>
))}
</div>
</section>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/animated-counter.jsonUsage
import { AnimatedCounter } from "@/components/facade/animated-counter";
export default function Page() {
return <AnimatedCounter />;
}Props
AnimatedCounterProps is exported, so you can extend or wrap it. Every prop has a default; <AnimatedCounter /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| value* | number | Target value. |
| from | number | Starting value. Defaults to 0. |
| prefix | string | Text before the number, e.g. "$". |
| suffix | string | Text after the number, e.g. "+", "M", "%". |
| decimals | number | Fixed number of decimals. Defaults to 0. |
| locale | string | BCP 47 locale for Intl.NumberFormat. Defaults to "en-US". |
| formatOptions | Intl.NumberFormatOptions | Extra Intl.NumberFormat options (merged after decimals). |
| duration | number | Animation duration in seconds. Defaults to 1.8. |
| delay | number | Delay in seconds once in view. Defaults to 0. |
| once | boolean | Animate only the first time it enters the viewport. Defaults to true. |
| className | string |
* required
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade animated counter to this page and adapt the copy to our business.