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
default-example.tsx
1"use client";23import { 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";89const useRefreshableCounter = (initialValue = 128_340) => {10 const [refreshKey, setRefreshKey] = useState(0);11 const [counterState, setCounterState] = useState({12 value: initialValue,13 previousValue: initialValue,14 });1516 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 };2627 return { ...counterState, refreshKey, refresh };28};2930export function LiveCounterDefaultExample() {31 const { value, previousValue, refreshKey, refresh } = useRefreshableCounter();3233 return (34 <Card>35 <CardContent className="space-y-4">36 <LiveCounter37 refreshKey={refreshKey}38 value={value}39 previousValue={previousValue}40 label="Active Users"41 formatValue={formatCompact}42 suffix="+"43 showTrend44 size="xl"45 />4647 <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 Refresh51 </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
size-example.tsx
1"use client";23import { 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";89const useRefreshableCounter = (initialValue = 128_340) => {10 const [refreshKey, setRefreshKey] = useState(0);11 const [counterState, setCounterState] = useState({12 value: initialValue,13 previousValue: initialValue,14 });1516 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 };2627 return { ...counterState, refreshKey, refresh };28};2930export function LiveCounterSizeExample() {31 const { value, previousValue, refreshKey, refresh } = useRefreshableCounter();32 const sizes = ["xs", "sm", "md", "lg", "xl", "2xl"] as const;3334 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 Refresh40 </Button>4142 <div className="grid grid-cols-1 gap-3 md:grid-cols-2">43 {sizes.map((size) => (44 <div45 key={size}46 className="min-w-0 rounded-lg border border-border/60 bg-muted/20 p-4"47 >48 <LiveCounter49 refreshKey={refreshKey}50 value={value}51 previousValue={previousValue}52 label={size}53 formatValue={formatCompact}54 suffix="+"55 showTrend56 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
trend-example.tsx
1"use client";23import { 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";89const 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;3940const useRefreshableCounter = (initialValue = 128_340) => {41 const [refreshKey, setRefreshKey] = useState(0);42 const [counterState, setCounterState] = useState({43 value: initialValue,44 previousValue: initialValue,45 });4647 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 };5758 return { ...counterState, refreshKey, refresh };59};6061export function LiveCounterTrendExample() {62 const { value, previousValue, refreshKey, refresh } = useRefreshableCounter();6364 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 Refresh70 </Button>7172 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">73 {TREND_VARIANTS.map((variant) => (74 <div75 key={variant.key}76 className="min-w-0 rounded-lg border border-border/60 bg-muted/20 p-4"77 >78 <LiveCounter79 refreshKey={refreshKey}80 value={variant.getValue(value)}81 previousValue={variant.getPrevious(value, previousValue)}82 label={variant.label}83 formatValue={formatCompact}84 showTrend85 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 off91 </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
| Name | Type | Default | Description |
|---|---|---|---|
| value | number | required | Current numeric value to display |
| previousValue | number | undefined | Prior value used to derive trend when `showTrend` is true |
| formatValue | (value: number) => string | formatWithCommas | Formatter for the displayed number |
| prefix | ReactNode | undefined | Text or node before the number |
| suffix | ReactNode | undefined | Text or node after the number |
| size | LiveCounterSize | "md" | Typography scale: xs through 2xl |
| label | string | undefined | Optional label rendered above the value |
| showTrend | boolean | false | Show trend icon when `previousValue` is provided (derived from value vs previousValue) |
| glow | boolean | true | Trend-colored text shadow when value differs from `previousValue` |
| refreshKey | string | number | undefined | Bump when the user explicitly refreshes to re-run the mount fade animation |
| className | string | undefined | Classes on the outer shell |
| numberClassName | string | undefined | Classes on the number element |
| aria-label | string | undefined | Accessible label for screen readers |