Stat Counter
Fintech & dataLocale-formatted count-up on scroll-in with optional delta badge.
▲ 12.4%
Assets under managementUptime
▼ 2.1%
CustomersInstall
npx shadcn@latest add https://labs.duku.design/r/stat-counter.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Stat Counter component (get_component "stat-counter", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Scroll it into view | Counts up over 1.6s power2.out, tabular-nums prevents jitter |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value / from | number | — | Count range. |
| decimals / locale / prefix / suffix | … | — | Intl formatting. |
| delta | number | — | ▲/▼ % badge. |
Source — registry/default/fintech/stat-counter.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
gsap.registerPlugin(ScrollTrigger);
export interface StatCounterProps
extends React.HTMLAttributes<HTMLDivElement> {
value: number;
from?: number;
duration?: number;
decimals?: number;
prefix?: string;
suffix?: string;
label?: string;
/** % change; renders a ▲/▼ badge in the sanctioned semantic colors. */
delta?: number;
locale?: string;
ref?: React.Ref<HTMLDivElement>;
}
export function StatCounter({
value,
from = 0,
duration = 1.6,
decimals = 0,
prefix = "",
suffix = "",
label,
delta,
locale = "en-IN",
className,
ref,
...rest
}: StatCounterProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const numberRef = React.useRef<HTMLSpanElement>(null);
const reduced = useReducedMotion();
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const format = React.useCallback(
(n: number) =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(n),
[locale, decimals]
);
useGSAP(
() => {
const node = numberRef.current;
if (!node) return;
const write = (n: number) => {
node.textContent = `${prefix}${format(n)}${suffix}`;
};
if (reduced) {
write(value);
return;
}
write(from);
const proxy = { val: from };
gsap.to(proxy, {
val: value,
duration,
ease: "power2.out",
onUpdate: () => write(proxy.val),
scrollTrigger: {
trigger: rootRef.current,
start: "top 88%",
once: true,
},
});
},
{ dependencies: [value, from, duration, prefix, suffix, format, reduced], scope: rootRef }
);
const deltaPositive = (delta ?? 0) >= 0;
return (
<div
ref={rootRef}
data-slot="stat-counter"
className={cn("inline-flex flex-col", className)}
{...rest}
>
<div className="flex items-baseline gap-2">
<span
ref={numberRef}
data-slot="value"
className="text-4xl font-semibold tracking-tight text-foreground tabular-nums"
/>
{delta != null ? (
<span
data-slot="delta"
className={cn(
"text-xs font-medium tabular-nums",
deltaPositive
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
)}
>
{deltaPositive ? "▲" : "▼"} {Math.abs(delta)}%
</span>
) : null}
</div>
{label ? (
<span data-slot="label" className="mt-1 text-sm text-muted-foreground">
{label}
</span>
) : null}
</div>
);
}