Componentmedia
Countup
Mechanical turnstile odometer counter with animated tally and slot frames.
Preview
Live Preview
LIVE TURNSTILE ADMISSIONS
8,372
TOTAL 0 STUBS
VIP: 0
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add countupOr install the full npm package:
npm install bigbulluiUsage
import { Countup } from "@/components/ui/countup";
<Countup end={8492} duration={1500} label="TICKETS ADMITTED" />Source code
countup.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export interface CountupProps extends React.HTMLAttributes<HTMLDivElement> {
end: number;
start?: number;
duration?: number; // duration in ms
prefix?: string;
suffix?: string;
separator?: string;
decimals?: number;
label?: string;
variant?: "odometer" | "minimal" | "badge";
className?: string;
}
export function Countup({
end,
start = 0,
duration = 1500,
prefix = "",
suffix = "",
separator = ",",
decimals = 0,
label = "TICKETS ADMITTED",
variant = "odometer",
className,
...props
}: CountupProps) {
const [current, setCurrent] = React.useState(start);
React.useEffect(() => {
let startTimestamp: number | null = null;
let animationFrameId: number;
const step = (timestamp: number) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
// Ease out quartic for smooth mechanical slowdown
const easeOut = 1 - Math.pow(1 - progress, 4);
const nextValue = start + (end - start) * easeOut;
setCurrent(nextValue);
if (progress < 1) {
animationFrameId = requestAnimationFrame(step);
}
};
animationFrameId = requestAnimationFrame(step);
return () => cancelAnimationFrame(animationFrameId);
}, [end, start, duration]);
const formatNumber = (num: number) => {
const fixed = num.toFixed(decimals);
const parts = fixed.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator);
return parts.join(".");
};
const formattedValue = formatNumber(current);
if (variant === "badge") {
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded border-2 border-foreground bg-accent px-2.5 py-1 font-mono text-xs font-bold text-accent-foreground shadow-xs select-none",
className
)}
{...props}
>
<span>{prefix}</span>
<span className="tabular-nums">{formattedValue}</span>
<span>{suffix}</span>
</div>
);
}
if (variant === "minimal") {
return (
<div className={cn("inline-flex items-baseline gap-1 font-mono select-none", className)} {...props}>
{prefix && <span className="text-sm font-bold text-muted-foreground">{prefix}</span>}
<span className="text-2xl font-black tabular-nums text-foreground">{formattedValue}</span>
{suffix && <span className="text-sm font-bold text-muted-foreground">{suffix}</span>}
</div>
);
}
return (
<div
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border-2 border-foreground bg-card p-4 font-mono select-none shadow-sm",
className
)}
{...props}
>
{/* Decorative side ticket notches */}
<div
aria-hidden="true"
className="absolute -left-1.5 top-1/2 -translate-y-1/2 size-3 rounded-full border border-foreground bg-background"
/>
<div
aria-hidden="true"
className="absolute -right-1.5 top-1/2 -translate-y-1/2 size-3 rounded-full border border-foreground bg-background"
/>
{label && (
<span className="mb-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</span>
)}
{/* Mechanical odometer slot display */}
<div className="flex items-center rounded border border-foreground bg-secondary/80 px-3 py-1.5 shadow-inner">
{prefix && <span className="mr-1 text-sm font-bold text-muted-foreground">{prefix}</span>}
<span className="text-2xl font-black tracking-wider tabular-nums text-foreground sm:text-3xl">
{formattedValue}
</span>
{suffix && <span className="ml-1 text-xs font-bold text-muted-foreground">{suffix}</span>}
</div>
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| end | number | Target final number value to count up to (required). |
| start | number | Starting initial number value (default 0). |
| duration | number | Animation duration in milliseconds (default 1500). |
| prefix | string | Text or symbol prepended to number. |
| suffix | string | Text or symbol appended to number. |
| separator | string | Thousands separator character (default ','). |
| decimals | number | Number of decimal places (default 0). |
| label | string | Eyebrow label above odometer display. |
| variant | "odometer" | "minimal" | "badge" | Display style variant (default 'odometer'). |