Stat Card
Original · freeA metric card with value, trend arrow and a small sparkline.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/card-stat.json"use client";
import { useId, useMemo, useState } from "react";
import { useReducedMotion } from "motion/react";
type Timeframe = "7D" | "30D" | "90D";
type Point = {
value: string;
delta: number;
spark: number[];
};
type Metric = {
id: string;
label: string;
hint: string;
positiveIsGood: boolean;
data: Record<Timeframe, Point>;
};
const TIMEFRAMES: Timeframe[] = ["7D", "30D", "90D"];
const METRICS: Metric[] = [
{
id: "mrr",
label: "Monthly Recurring Revenue",
hint: "Stripe · net of refunds",
positiveIsGood: true,
data: {
"7D": { value: "$48,250", delta: 4.2, spark: [41, 42, 40, 44, 45, 47, 48.25] },
"30D": { value: "$48,250", delta: 12.4, spark: [39, 40, 42, 41, 44, 46, 48.25] },
"90D": { value: "$48,250", delta: 31.8, spark: [30, 33, 32, 37, 39, 43, 48.25] },
},
},
{
id: "users",
label: "Active Users",
hint: "7-day rolling",
positiveIsGood: true,
data: {
"7D": { value: "12,840", delta: 2.1, spark: [12.1, 12.3, 12.2, 12.5, 12.6, 12.7, 12.84] },
"30D": { value: "12,840", delta: 8.1, spark: [11.6, 11.9, 12.0, 12.2, 12.4, 12.6, 12.84] },
"90D": { value: "12,840", delta: 19.5, spark: [10.4, 10.9, 11.3, 11.7, 12.1, 12.5, 12.84] },
},
},
{
id: "churn",
label: "Monthly Churn",
hint: "logo churn",
positiveIsGood: false,
data: {
"7D": { value: "2.3%", delta: -0.2, spark: [2.6, 2.5, 2.6, 2.4, 2.5, 2.4, 2.3] },
"30D": { value: "2.3%", delta: -0.6, spark: [3.1, 2.9, 2.8, 2.7, 2.6, 2.4, 2.3] },
"90D": { value: "2.3%", delta: -1.4, spark: [3.9, 3.6, 3.3, 3.0, 2.8, 2.5, 2.3] },
},
},
{
id: "aov",
label: "Avg. Order Value",
hint: "checkout · USD",
positiveIsGood: true,
data: {
"7D": { value: "$126.40", delta: -1.2, spark: [129, 128, 129, 127, 128, 127, 126.4] },
"30D": { value: "$126.40", delta: -3.4, spark: [132, 131, 130, 129, 128, 127, 126.4] },
"90D": { value: "$126.40", delta: 5.6, spark: [119, 121, 120, 123, 124, 125, 126.4] },
},
},
];
function Sparkline({
points,
good,
animate,
uid,
}: {
points: number[];
good: boolean;
animate: boolean;
uid: string;
}) {
const W = 148;
const H = 46;
const P = 4;
const { line, area, last } = useMemo(() => {
const min = Math.min(...points);
const max = Math.max(...points);
const range = max - min || 1;
const step = (W - P * 2) / (points.length - 1);
const coords = points.map((v, i) => {
const x = P + i * step;
const y = P + (H - P * 2) * (1 - (v - min) / range);
return [x, y] as const;
});
const l = coords.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
const first = coords[0];
const lst = coords[coords.length - 1];
const a =
`M ${first[0].toFixed(2)},${(H - P).toFixed(2)} ` +
coords.map(([x, y]) => `L ${x.toFixed(2)},${y.toFixed(2)}`).join(" ") +
` L ${lst[0].toFixed(2)},${(H - P).toFixed(2)} Z`;
return { line: l, area: a, last: lst };
}, [points]);
const stroke = good ? "#10b981" : "#f43f5e";
return (
<svg
viewBox={`0 0 ${W} ${H}`}
width="100%"
height={H}
preserveAspectRatio="none"
role="img"
aria-hidden="true"
className="overflow-visible"
>
<defs>
<linearGradient id={`${uid}-fill`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#${uid}-fill)`} className={animate ? "cardstat-area" : ""} />
<polyline
points={line}
fill="none"
stroke={stroke}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
className={animate ? "cardstat-draw" : ""}
/>
<circle cx={last[0]} cy={last[1]} r={3} fill={stroke} className={animate ? "cardstat-dot" : ""} />
<circle cx={last[0]} cy={last[1]} r={3} fill="none" stroke={stroke} strokeOpacity="0.35" strokeWidth={2}>
{animate ? (
<animate attributeName="r" from="3" to="7" dur="1.6s" begin="0.8s" repeatCount="indefinite" />
) : null}
{animate ? (
<animate
attributeName="stroke-opacity"
from="0.35"
to="0"
dur="1.6s"
begin="0.8s"
repeatCount="indefinite"
/>
) : null}
</circle>
</svg>
);
}
function TrendBadge({ delta, good }: { delta: number; good: boolean }) {
const up = delta >= 0;
return (
<span
className={[
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums",
good
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400"
: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-400",
].join(" ")}
>
<svg
viewBox="0 0 16 16"
width="12"
height="12"
fill="none"
aria-hidden="true"
className={up ? "" : "rotate-180"}
>
<path
d="M8 13V3M8 3L4 7M8 3l4 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{up ? "+" : "−"}
{Math.abs(delta).toFixed(1)}%
</span>
);
}
function PinButton({ metricLabel }: { metricLabel: string }) {
const [pinned, setPinned] = useState(false);
return (
<button
type="button"
aria-pressed={pinned}
aria-label={pinned ? `Unpin ${metricLabel} from dashboard` : `Pin ${metricLabel} to dashboard`}
onClick={() => setPinned((p) => !p)}
className={[
"grid h-8 w-8 shrink-0 place-items-center rounded-lg border transition",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"focus-visible:ring-indigo-500 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
pinned
? "border-indigo-300 bg-indigo-50 text-indigo-600 dark:border-indigo-500/40 dark:bg-indigo-500/15 dark:text-indigo-400"
: "border-slate-200 bg-white text-slate-400 hover:text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500 dark:hover:text-slate-300",
].join(" ")}
>
<svg viewBox="0 0 24 24" width="15" height="15" fill={pinned ? "currentColor" : "none"} aria-hidden="true">
<path
d="M9 4h6l-1 6 3 3v2H7v-2l3-3-1-6z M12 15v5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
function StatCard({
metric,
timeframe,
animate,
}: {
metric: Metric;
timeframe: Timeframe;
animate: boolean;
}) {
const uid = useId().replace(/[:]/g, "");
const point = metric.data[timeframe];
const good = point.delta >= 0 === metric.positiveIsGood;
return (
<div
className={[
"group relative flex flex-col gap-4 overflow-hidden rounded-2xl border p-5 transition duration-300",
"border-slate-200 bg-white shadow-sm hover:-translate-y-0.5 hover:shadow-lg",
"dark:border-slate-800 dark:bg-slate-900 dark:shadow-none dark:hover:border-slate-700",
].join(" ")}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-slate-300/70 to-transparent dark:via-slate-600/60"
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-600 dark:text-slate-300">{metric.label}</p>
<p className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">{metric.hint}</p>
</div>
<PinButton metricLabel={metric.label} />
</div>
<div className="flex items-end justify-between gap-3">
<p className="text-3xl font-semibold tracking-tight tabular-nums text-slate-900 dark:text-white">
{point.value}
</p>
<TrendBadge delta={point.delta} good={good} />
</div>
<div className="-mx-1">
<Sparkline key={`${metric.id}-${timeframe}`} points={point.spark} good={good} animate={animate} uid={uid} />
</div>
<p className="text-xs text-slate-400 dark:text-slate-500">
vs. previous{" "}
{timeframe === "7D" ? "7 days" : timeframe === "30D" ? "30 days" : "90 days"}
</p>
</div>
);
}
export default function CardStat() {
const reduce = useReducedMotion();
const animate = !reduce;
const [timeframe, setTimeframe] = useState<Timeframe>("30D");
return (
<section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20">
<style>{`
.cardstat-draw {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: cardstat-draw 1.1s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.cardstat-area {
opacity: 0;
animation: cardstat-area 0.7s ease-out 0.55s forwards;
}
.cardstat-dot {
opacity: 0;
animation: cardstat-area 0.4s ease-out 0.95s forwards;
}
@keyframes cardstat-draw { to { stroke-dashoffset: 0; } }
@keyframes cardstat-area { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.cardstat-draw, .cardstat-area, .cardstat-dot {
animation: none;
stroke-dashoffset: 0;
opacity: 1;
}
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
Growth overview
</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Key metrics across your workspace, updated hourly.
</p>
</div>
<div
role="group"
aria-label="Select comparison timeframe"
className="inline-flex self-start rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-900 sm:self-auto"
>
{TIMEFRAMES.map((tf) => {
const active = tf === timeframe;
return (
<button
key={tf}
type="button"
aria-pressed={active}
onClick={() => setTimeframe(tf)}
className={[
"rounded-lg px-3.5 py-1.5 text-sm font-medium tabular-nums transition",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"focus-visible:ring-indigo-500 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900",
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
].join(" ")}
>
{tf}
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{METRICS.map((metric) => (
<StatCard key={metric.id} metric={metric} timeframe={timeframe} animate={animate} />
))}
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Product Card
OriginalA product card with image, price and add-to-cart feedback.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Blog Card
OriginalA blog post card with cover image, tag and meta row.

