All components

Live Counter

Numeric display for metrics. Value updates are instant; pass `refreshKey` (bump on refresh) for a mount fade. Optional `previousValue` drives trend when `showTrend` is enabled.

Default
Compact KPI card with formatted value and up trend.
128.3K

Previous: 128.3K | Current: 128.3K

1"use client";
2
3import { useState } from "react";
4import { LiveCounter, formatCompact } from "@/registry/ui";
5import { Button } from "@/components/ui/button";
6import { Card, CardContent } from "@/components/ui/card";
7import { RefreshCw } from "lucide-react";
8
9const useRefreshableCounter = (initialValue = 128_340) => {
10 const [refreshKey, setRefreshKey] = useState(0);
11 const [counterState, setCounterState] = useState({
12 value: initialValue,
13 previousValue: initialValue,
14 });
15
16 const refresh = () => {
17 setCounterState((prev) => {
18 const delta = Math.round((Math.random() - 0.35) * 2200);
19 return {
20 previousValue: prev.value,
21 value: Math.max(95_000, prev.value + delta),
22 };
23 });
24 setRefreshKey((tick) => tick + 1);
25 };
26
27 return { ...counterState, refreshKey, refresh };
28};
29
30export function LiveCounterDefaultExample() {
31 const { value, previousValue, refreshKey, refresh } = useRefreshableCounter();
32
33 return (
34 <Card>
35 <CardContent className="space-y-4">
36 <LiveCounter
37 refreshKey={refreshKey}
38 value={value}
39 previousValue={previousValue}
40 label="Active Users"
41 formatValue={formatCompact}
42 suffix="+"
43 showTrend
44 size="xl"
45 />
46
47 <div className="flex flex-wrap items-center gap-3">
48 <Button variant="outline" size="sm" className="gap-1.5" onClick={refresh}>
49 <RefreshCw className="size-3.5" />
50 Refresh
51 </Button>
52 <p className="font-mono text-xs text-muted-foreground">
53 Previous: {formatCompact(previousValue)} | Current:{" "}
54 {formatCompact(value)}
55 </p>
56 </div>
57 </CardContent>
58 </Card>
59 );
60}
Size variants
Compact KPI card with formatted value and up trend.
128.3K
128.3K
128.3K
128.3K
128.3K
128.3K
1"use client";
2
3import { useState } from "react";
4import { LiveCounter, formatCompact } from "@/registry/ui";
5import { Button } from "@/components/ui/button";
6import { Card, CardContent } from "@/components/ui/card";
7import { RefreshCw } from "lucide-react";
8
9const useRefreshableCounter = (initialValue = 128_340) => {
10 const [refreshKey, setRefreshKey] = useState(0);
11 const [counterState, setCounterState] = useState({
12 value: initialValue,
13 previousValue: initialValue,
14 });
15
16 const refresh = () => {
17 setCounterState((prev) => {
18 const delta = Math.round((Math.random() - 0.35) * 2200);
19 return {
20 previousValue: prev.value,
21 value: Math.max(95_000, prev.value + delta),
22 };
23 });
24 setRefreshKey((tick) => tick + 1);
25 };
26
27 return { ...counterState, refreshKey, refresh };
28};
29
30export function LiveCounterSizeExample() {
31 const { value, previousValue, refreshKey, refresh } = useRefreshableCounter();
32 const sizes = ["xs", "sm", "md", "lg", "xl", "2xl"] as const;
33
34 return (
35 <Card className="w-full">
36 <CardContent className="space-y-4">
37 <Button variant="outline" size="sm" className="gap-1.5" onClick={refresh}>
38 <RefreshCw className="size-3.5" />
39 Refresh
40 </Button>
41
42 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
43 {sizes.map((size) => (
44 <div
45 key={size}
46 className="min-w-0 rounded-lg border border-border/60 bg-muted/20 p-4"
47 >
48 <LiveCounter
49 refreshKey={refreshKey}
50 value={value}
51 previousValue={previousValue}
52 label={size}
53 formatValue={formatCompact}
54 suffix="+"
55 showTrend
56 size={size}
57 className="max-w-[18rem]"
58 />
59 </div>
60 ))}
61 </div>
62 </CardContent>
63 </Card>
64 );
65}
Trend variants
Compact KPI card with formatted value and up trend.
129.3K
127.3K
128.3K
128.3K

glow off

1"use client";
2
3import { useState } from "react";
4import { LiveCounter, formatCompact } from "@/registry/ui";
5import { Button } from "@/components/ui/button";
6import { Card, CardContent } from "@/components/ui/card";
7import { RefreshCw } from "lucide-react";
8
9const TREND_VARIANTS = [
10 {
11 key: "up",
12 label: "Up",
13 getValue: (value: number) => value + 1000,
14 getPrevious: (value: number) => value,
15 glow: true,
16 },
17 {
18 key: "down",
19 label: "Down",
20 getValue: (value: number) => value - 1000,
21 getPrevious: (value: number) => value,
22 glow: true,
23 },
24 {
25 key: "neutral",
26 label: "Neutral",
27 getValue: (value: number) => value,
28 getPrevious: (value: number) => value,
29 glow: true,
30 },
31 {
32 key: "live",
33 label: "Live",
34 getValue: (value: number) => value,
35 getPrevious: (_value: number, previousValue: number) => previousValue,
36 glow: false,
37 },
38] as const;
39
40const useRefreshableCounter = (initialValue = 128_340) => {
41 const [refreshKey, setRefreshKey] = useState(0);
42 const [counterState, setCounterState] = useState({
43 value: initialValue,
44 previousValue: initialValue,
45 });
46
47 const refresh = () => {
48 setCounterState((prev) => {
49 const delta = Math.round((Math.random() - 0.35) * 2200);
50 return {
51 previousValue: prev.value,
52 value: Math.max(95_000, prev.value + delta),
53 };
54 });
55 setRefreshKey((tick) => tick + 1);
56 };
57
58 return { ...counterState, refreshKey, refresh };
59};
60
61export function LiveCounterTrendExample() {
62 const { value, previousValue, refreshKey, refresh } = useRefreshableCounter();
63
64 return (
65 <Card className="w-full">
66 <CardContent className="space-y-4">
67 <Button variant="outline" size="sm" className="gap-1.5" onClick={refresh}>
68 <RefreshCw className="size-3.5" />
69 Refresh
70 </Button>
71
72 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
73 {TREND_VARIANTS.map((variant) => (
74 <div
75 key={variant.key}
76 className="min-w-0 rounded-lg border border-border/60 bg-muted/20 p-4"
77 >
78 <LiveCounter
79 refreshKey={refreshKey}
80 value={variant.getValue(value)}
81 previousValue={variant.getPrevious(value, previousValue)}
82 label={variant.label}
83 formatValue={formatCompact}
84 showTrend
85 glow={variant.glow}
86 size="lg"
87 />
88 {variant.key === "live" && (
89 <p className="mt-2 font-mono text-[10px] text-muted-foreground uppercase tracking-wide">
90 glow off
91 </p>
92 )}
93 </div>
94 ))}
95 </div>
96 </CardContent>
97 </Card>
98 );
99}

Installation & source

Install via the shadcn CLI or copy the registry files manually.

bash
npx shadcn@latest add @tt-ui/live-counter

Props

NameTypeDefaultDescription
valuenumberrequiredCurrent numeric value to display
previousValuenumberundefinedPrior value used to derive trend when `showTrend` is true
formatValue(value: number) => stringformatWithCommasFormatter for the displayed number
prefixReactNodeundefinedText or node before the number
suffixReactNodeundefinedText or node after the number
sizeLiveCounterSize"md"Typography scale: xs through 2xl
labelstringundefinedOptional label rendered above the value
showTrendbooleanfalseShow trend icon when `previousValue` is provided (derived from value vs previousValue)
glowbooleantrueTrend-colored text shadow when value differs from `previousValue`
refreshKeystring | numberundefinedBump when the user explicitly refreshes to re-run the mount fade animation
classNamestringundefinedClasses on the outer shell
numberClassNamestringundefinedClasses on the number element
aria-labelstringundefinedAccessible label for screen readers