Rotating Counter
Mount-only stat counter that animates from zero to a target value once on page load. Optional `steps` caps display at the last milestone and hides the suffix below the first (e.g. 101 → 100+). Not for live or streaming metrics.
Default
Homepage-style stat row with suffixes.
120+
Components
45+
Blocks
10,000+
Developers
default-example.tsx
1"use client";23import { RotatingCounter } from "@/registry/ui";4import { Card, CardContent } from "@/components/ui/card";56const STATS = [7 { label: "Components", value: 120, suffix: "+" },8 { label: "Blocks", value: 45, suffix: "+" },9 { label: "Developers", value: 10_000, suffix: "+" },10] as const;1112export function RotatingCounterDefaultExample() {13 return (14 <Card>15 <CardContent>16 <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">17 {STATS.map((stat) => (18 <div key={stat.label} className="flex flex-col gap-1 text-center">19 <RotatingCounter20 value={stat.value}21 suffix={stat.suffix}22 className="justify-center text-3xl font-bold tracking-tight"23 aria-label={`${stat.value}${stat.suffix} ${stat.label}`}24 />25 <p className="text-sm text-muted-foreground">{stat.label}</p>26 </div>27 ))}28 </div>29 </CardContent>30 </Card>31 );32}
Formatting
Percent, compact, and currency formatters.
Uptime
99.9%Active users
12.4K+Revenue saved
$42,300formatting-example.tsx
1"use client";23import {4 RotatingCounter,5 formatCompact,6 formatCurrency,7 formatPercent,8} from "@/registry/ui";9import { Card, CardContent } from "@/components/ui/card";1011export function RotatingCounterFormattingExample() {12 return (13 <Card>14 <CardContent>15 <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">16 <div className="flex flex-col gap-1">17 <p className="text-sm text-muted-foreground">Uptime</p>18 <RotatingCounter19 value={99.9}20 formatValue={formatPercent}21 className="text-2xl font-bold"22 aria-label="99.9% uptime"23 />24 </div>2526 <div className="flex flex-col gap-1">27 <p className="text-sm text-muted-foreground">Active users</p>28 <RotatingCounter29 value={12_450}30 formatValue={formatCompact}31 suffix="+"32 className="text-2xl font-bold"33 aria-label="12.4K+ active users"34 />35 </div>3637 <div className="flex flex-col gap-1">38 <p className="text-sm text-muted-foreground">Revenue saved</p>39 <RotatingCounter40 value={42_300}41 formatValue={(v) => formatCurrency(v)}42 className="text-2xl font-bold"43 aria-label="$42,300 revenue saved"44 />45 </div>46 </div>47 </CardContent>48 </Card>49 );50}
Key re-animation
Bump `key` to remount and replay the roll-in animation.
Downloads
8,420Bump key to re-mount and re-animate. Prop updates alone do not restart the roll.
key-example.tsx
1"use client";23import { useState } from "react";4import { RotatingCounter } from "@/registry/ui";5import { Button } from "@/components/ui/button";6import { Card, CardContent } from "@/components/ui/card";7import { RefreshCw } from "lucide-react";89export function RotatingCounterKeyExample() {10 const [key, setKey] = useState(0);11 const [target, setTarget] = useState(8_420);1213 const replay = () => {14 setTarget((prev) => prev + Math.round(Math.random() * 500));15 setKey((tick) => tick + 1);16 };1718 return (19 <Card>20 <CardContent className="space-y-4">21 <div className="flex flex-col gap-1">22 <p className="text-sm text-muted-foreground">Downloads</p>23 <RotatingCounter24 key={key}25 value={target}26 className="text-3xl font-bold"27 aria-label={`${target} downloads`}28 />29 </div>3031 <Button32 variant="outline"33 size="sm"34 className="gap-1.5"35 onClick={replay}36 >37 <RefreshCw className="size-3.5" />38 Replay animation39 </Button>4041 <p className="font-mono text-xs text-muted-foreground">42 Bump <code className="text-foreground">key</code> to re-mount and43 re-animate. Prop updates alone do not restart the roll.44 </p>45 </CardContent>46 </Card>47 );48}
Steps
Suffix threshold and display cap via `steps` (e.g. 12 → no +, 45 → 45+, 101 → 100+).
12
Below threshold
No suffix below 25
45+
Mid range
Shows 45+
100+
Above cap
Caps at 100+
steps-example.tsx
1"use client";23import { RotatingCounter } from "@/registry/ui";4import { Card, CardContent } from "@/components/ui/card";56const STEPS = [25, 50, 100] as const;78const STATS = [9 {10 label: "Below threshold",11 value: 12,12 hint: "No suffix below 25",13 },14 {15 label: "Mid range",16 value: 45,17 hint: "Shows 45+",18 },19 {20 label: "Above cap",21 value: 101,22 hint: "Caps at 100+",23 },24] as const;2526export function RotatingCounterStepsExample() {27 return (28 <Card>29 <CardContent>30 <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">31 {STATS.map((stat) => (32 <div key={stat.label} className="flex flex-col gap-1 text-center">33 <RotatingCounter34 value={stat.value}35 steps={STEPS}36 suffix="+"37 className="justify-center text-3xl font-bold tracking-tight"38 aria-label={`${stat.label}: ${stat.hint}`}39 />40 <p className="text-sm font-medium">{stat.label}</p>41 <p className="text-xs text-muted-foreground">{stat.hint}</p>42 </div>43 ))}44 </div>45 </CardContent>46 </Card>47 );48}
Installation & source
Install via the shadcn CLI or copy the registry files manually.
bash
npx shadcn@latest add @tt-ui/rotating-counter
Props
| Name | Type | Default | Description |
|---|---|---|---|
| value | number | required | Target numeric value (frozen on mount) |
| duration | number | 1100 | Animation duration in milliseconds |
| formatValue | (value: number) => string | formatWithCommas (thousand separators) | Formatter for the displayed number |
| prefix | ReactNode | undefined | Text or node before the number |
| suffix | ReactNode | undefined | Text or node after the number. With `steps`, only shown when display value is at or above the first step. Defaults to `"+"` when `steps` is set. |
| steps | readonly number[] | undefined | Display milestones: first value is the suffix threshold, last value caps the shown number when `value` exceeds it |
| label | string | undefined | Optional label rendered below the value |
| startFromZero | boolean | true | When true, animates from 0; when false, renders the final value immediately |
| className | string | undefined | Classes on the outer wrapper |
| aria-label | string | undefined | Accessible label; screen readers hear the final value only |