Search

Search the site

All components

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

1"use client";
2
3import { RotatingCounter } from "@/registry/ui";
4import { Card, CardContent } from "@/components/ui/card";
5
6const STATS = [
7 { label: "Components", value: 120, suffix: "+" },
8 { label: "Blocks", value: 45, suffix: "+" },
9 { label: "Developers", value: 10_000, suffix: "+" },
10] as const;
11
12export 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 <RotatingCounter
20 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,300
1"use client";
2
3import {
4 RotatingCounter,
5 formatCompact,
6 formatCurrency,
7 formatPercent,
8} from "@/registry/ui";
9import { Card, CardContent } from "@/components/ui/card";
10
11export 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 <RotatingCounter
19 value={99.9}
20 formatValue={formatPercent}
21 className="text-2xl font-bold"
22 aria-label="99.9% uptime"
23 />
24 </div>
25
26 <div className="flex flex-col gap-1">
27 <p className="text-sm text-muted-foreground">Active users</p>
28 <RotatingCounter
29 value={12_450}
30 formatValue={formatCompact}
31 suffix="+"
32 className="text-2xl font-bold"
33 aria-label="12.4K+ active users"
34 />
35 </div>
36
37 <div className="flex flex-col gap-1">
38 <p className="text-sm text-muted-foreground">Revenue saved</p>
39 <RotatingCounter
40 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,420

Bump key to re-mount and re-animate. Prop updates alone do not restart the roll.

1"use client";
2
3import { 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";
8
9export function RotatingCounterKeyExample() {
10 const [key, setKey] = useState(0);
11 const [target, setTarget] = useState(8_420);
12
13 const replay = () => {
14 setTarget((prev) => prev + Math.round(Math.random() * 500));
15 setKey((tick) => tick + 1);
16 };
17
18 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 <RotatingCounter
24 key={key}
25 value={target}
26 className="text-3xl font-bold"
27 aria-label={`${target} downloads`}
28 />
29 </div>
30
31 <Button
32 variant="outline"
33 size="sm"
34 className="gap-1.5"
35 onClick={replay}
36 >
37 <RefreshCw className="size-3.5" />
38 Replay animation
39 </Button>
40
41 <p className="font-mono text-xs text-muted-foreground">
42 Bump <code className="text-foreground">key</code> to re-mount and
43 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+

1"use client";
2
3import { RotatingCounter } from "@/registry/ui";
4import { Card, CardContent } from "@/components/ui/card";
5
6const STEPS = [25, 50, 100] as const;
7
8const 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;
25
26export 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 <RotatingCounter
34 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

NameTypeDefaultDescription
valuenumberrequiredTarget numeric value (frozen on mount)
durationnumber1100Animation duration in milliseconds
formatValue(value: number) => stringformatWithCommas (thousand separators)Formatter for the displayed number
prefixReactNodeundefinedText or node before the number
suffixReactNodeundefinedText 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.
stepsreadonly number[]undefinedDisplay milestones: first value is the suffix threshold, last value caps the shown number when `value` exceeds it
labelstringundefinedOptional label rendered below the value
startFromZerobooleantrueWhen true, animates from 0; when false, renders the final value immediately
classNamestringundefinedClasses on the outer wrapper
aria-labelstringundefinedAccessible label; screen readers hear the final value only