Progress Ring Card
Original · freeA card featuring an animated circular progress ring.
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-progress-ring.json"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
type RingProps = {
pct: number;
size: number;
stroke: number;
from: string;
to: string;
reduced: boolean;
ariaLabel: string;
children: ReactNode;
};
function Ring({
pct,
size,
stroke,
from,
to,
reduced,
ariaLabel,
children,
}: RingProps) {
const uid = useId().replace(/[:]/g, "");
const gradId = `cpr-grad-${uid}`;
const glowId = `cpr-glow-${uid}`;
const clamped = Math.max(0, Math.min(100, pct));
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const offset = c * (1 - clamped / 100);
const center = size / 2;
return (
<div
className="relative shrink-0"
style={{ width: size, height: size }}
role="progressbar"
aria-label={ariaLabel}
aria-valuenow={Math.round(clamped)}
aria-valuemin={0}
aria-valuemax={100}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90 overflow-visible"
aria-hidden="true"
>
<defs>
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={from} />
<stop offset="100%" stopColor={to} />
</linearGradient>
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<circle
cx={center}
cy={center}
r={r}
fill="none"
strokeWidth={stroke}
className="stroke-slate-200/90 dark:stroke-slate-700/70"
/>
<circle
cx={center}
cy={center}
r={r}
fill="none"
strokeWidth={stroke}
strokeLinecap="round"
stroke={`url(#${gradId})`}
strokeDasharray={c}
strokeDashoffset={offset}
filter={`url(#${glowId})`}
style={{
transition: reduced
? "none"
: "stroke-dashoffset 800ms cubic-bezier(0.22,1,0.36,1)",
}}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
{children}
</div>
</div>
);
}
function useCountUp(target: number, reduced: boolean): number {
const [value, setValue] = useState<number>(target);
const prev = useRef<number>(target);
useEffect(() => {
if (reduced) {
prev.current = target;
setValue(target);
return;
}
const from = prev.current;
const to = target;
if (from === to) return;
const duration = 800;
const start = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setValue(Math.round(from + (to - from) * eased));
if (t < 1) {
raf = requestAnimationFrame(tick);
} else {
prev.current = to;
}
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, reduced]);
return value;
}
function CheckMark({ reduced }: { reduced: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-6 w-6"
style={reduced ? undefined : { animation: "cpr-pop 480ms ease-out both" }}
aria-hidden="true"
>
<path
d="M20 6 9 17l-5-5"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function Spinner() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeOpacity={0.25}
strokeWidth={3}
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
style={{ animation: "cpr-spin 700ms linear infinite" }}
/>
</svg>
);
}
const btnBase =
"inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-[transform,background-color,border-color,box-shadow] duration-150 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 disabled:cursor-not-allowed disabled:opacity-50";
const cardBase =
"relative flex flex-col rounded-3xl border border-slate-200/80 bg-white p-6 shadow-[0_1px_0_0_rgba(255,255,255,0.6)_inset,0_18px_40px_-24px_rgba(15,23,42,0.35)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_1px_0_0_rgba(255,255,255,0.04)_inset,0_20px_50px_-28px_rgba(0,0,0,0.9)]";
type Category = { key: string; label: string; gb: number; from: string; to: string };
const STORAGE_TOTAL = 200;
const STORAGE: Category[] = [
{ key: "photos", label: "Photos & Video", gb: 82.4, from: "#38bdf8", to: "#6366f1" },
{ key: "documents", label: "Documents", gb: 41.6, from: "#a78bfa", to: "#7c3aed" },
{ key: "backups", label: "Device Backups", gb: 23.1, from: "#34d399", to: "#0ea5e9" },
];
export default function CardProgressRing() {
const reduced = useReducedMotion() ?? false;
// Card 1 — Move goal
const STEP_GOAL = 10000;
const [steps, setSteps] = useState<number>(6250);
const displaySteps = useCountUp(steps, reduced);
const stepPct = Math.min(100, (steps / STEP_GOAL) * 100);
const stepsDone = steps >= STEP_GOAL;
const addSteps = (n: number) =>
setSteps((s) => Math.min(STEP_GOAL + 4000, s + n));
// Card 2 — Storage breakdown
const [activeCat, setActiveCat] = useState<string>(STORAGE[0].key);
const current = STORAGE.find((c) => c.key === activeCat) ?? STORAGE[0];
const usedGb = STORAGE.reduce((a, c) => a + c.gb, 0);
const catPct = (current.gb / STORAGE_TOTAL) * 100;
const displayGb = useCountUp(Math.round(current.gb * 10), reduced) / 10;
// Card 3 — Course progress
const TOTAL_LESSONS = 12;
const [lessons, setLessons] = useState<number>(7);
const [loading, setLoading] = useState<boolean>(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lessonPct = (lessons / TOTAL_LESSONS) * 100;
const displayLessonPct = useCountUp(Math.round(lessonPct), reduced);
const courseDone = lessons >= TOTAL_LESSONS;
const completeLesson = useCallback(() => {
if (loading || lessons >= TOTAL_LESSONS) return;
setLoading(true);
timer.current = setTimeout(
() => {
setLessons((l) => Math.min(TOTAL_LESSONS, l + 1));
setLoading(false);
},
reduced ? 0 : 650
);
}, [loading, lessons, reduced]);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes cpr-pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.18); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes cpr-spin { to { transform: rotate(360deg); } }
@keyframes cpr-rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.cpr-anim { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto max-w-5xl">
<header className="mb-12 max-w-xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Progress Rings
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
Goals worth watching fill up.
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Tactile, accessible cards with animated circular progress. Every
button really works — add activity, switch what the ring measures,
or complete a lesson.
</p>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Card 1 — Move goal (wide) */}
<article className={`${cardBase} md:col-span-2 md:flex-row md:items-center md:gap-8`}>
<div className="flex items-center gap-6">
<Ring
pct={stepPct}
size={148}
stroke={13}
from="#34d399"
to="#0ea5e9"
reduced={reduced}
ariaLabel={`Daily steps: ${steps} of ${STEP_GOAL}`}
>
{stepsDone ? (
<span className="flex flex-col items-center text-emerald-500 dark:text-emerald-400">
<span className="cpr-anim">
<CheckMark reduced={reduced} />
</span>
<span className="mt-1 text-xs font-semibold uppercase tracking-wide">
Done
</span>
</span>
) : (
<>
<span className="text-2xl font-bold tabular-nums">
{displaySteps.toLocaleString()}
</span>
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
of {STEP_GOAL.toLocaleString()}
</span>
</>
)}
</Ring>
<div className="md:hidden">
<p className="text-sm font-semibold text-slate-500 dark:text-slate-400">
Today
</p>
<p className="text-xl font-bold">Move Ring</p>
</div>
</div>
<div className="mt-6 flex-1 md:mt-0">
<div className="hidden md:block">
<p className="text-sm font-semibold text-slate-500 dark:text-slate-400">
Today · Wed 15 Jul
</p>
<h3 className="text-2xl font-bold tracking-tight">Move Ring</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{stepsDone
? "You hit your goal — nice work. Keep the streak alive."
: `${(STEP_GOAL - steps).toLocaleString()} steps to close your ring.`}
</p>
</div>
<div className="mt-5 flex flex-wrap gap-2.5">
<button
type="button"
onClick={() => addSteps(500)}
disabled={stepsDone}
className={`${btnBase} bg-slate-900 px-4 py-2.5 text-white shadow-sm hover:bg-slate-700 focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200`}
>
<PlusIcon /> 500 steps
</button>
<button
type="button"
onClick={() => addSteps(2000)}
disabled={stepsDone}
className={`${btnBase} border border-slate-300 bg-white px-4 py-2.5 text-slate-800 hover:border-slate-400 hover:bg-slate-50 focus-visible:ring-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700`}
>
<PlusIcon /> 2,000 steps
</button>
<button
type="button"
onClick={() => setSteps(0)}
aria-label="Reset step count"
className={`${btnBase} border border-slate-300 bg-white px-3 py-2.5 text-slate-500 hover:border-rose-300 hover:text-rose-600 focus-visible:ring-rose-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:border-rose-500/60 dark:hover:text-rose-400`}
>
<ResetIcon />
</button>
</div>
</div>
</article>
{/* Card 2 — Storage breakdown */}
<article className={cardBase}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold tracking-tight">iCloud+ Storage</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
{usedGb.toFixed(1)} GB of {STORAGE_TOTAL} GB used
</p>
</div>
<CloudIcon />
</div>
<div className="mt-4 flex justify-center">
<Ring
pct={catPct}
size={168}
stroke={14}
from={current.from}
to={current.to}
reduced={reduced}
ariaLabel={`${current.label}: ${current.gb} gigabytes of ${STORAGE_TOTAL}`}
>
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
{current.label}
</span>
<span className="text-3xl font-bold tabular-nums">
{displayGb.toFixed(1)}
</span>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
GB · {Math.round(catPct)}%
</span>
</Ring>
</div>
<div className="mt-5 flex flex-col gap-2" role="group" aria-label="Storage categories">
{STORAGE.map((cat) => {
const active = cat.key === activeCat;
return (
<button
key={cat.key}
type="button"
aria-pressed={active}
onClick={() => setActiveCat(cat.key)}
className={`${btnBase} justify-between px-3.5 py-2.5 ${
active
? "border border-transparent bg-slate-900 text-white focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900"
: "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 focus-visible:ring-slate-400 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
}`}
>
<span className="flex items-center gap-2.5">
<span
className="h-2.5 w-2.5 rounded-full"
style={{
background: `linear-gradient(135deg, ${cat.from}, ${cat.to})`,
}}
aria-hidden="true"
/>
{cat.label}
</span>
<span className="tabular-nums font-semibold">
{cat.gb.toFixed(1)} GB
</span>
</button>
);
})}
</div>
</article>
{/* Card 3 — Course progress */}
<article className={cardBase}>
<div className="flex items-start justify-between gap-3">
<div>
<span className="inline-flex rounded-full bg-violet-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-violet-700 dark:bg-violet-500/15 dark:text-violet-300">
In progress
</span>
<h3 className="mt-3 text-lg font-bold leading-snug tracking-tight">
Advanced TypeScript for React Engineers
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
{lessons} of {TOTAL_LESSONS} lessons complete
</p>
</div>
</div>
<div className="mt-2 flex justify-center">
<Ring
pct={lessonPct}
size={168}
stroke={14}
from="#a78bfa"
to="#6366f1"
reduced={reduced}
ariaLabel={`Course progress: ${lessons} of ${TOTAL_LESSONS} lessons`}
>
{courseDone ? (
<span className="flex flex-col items-center text-violet-500 dark:text-violet-300">
<span className="cpr-anim">
<CheckMark reduced={reduced} />
</span>
<span className="mt-1 text-xs font-semibold uppercase tracking-wide">
Certified
</span>
</span>
) : (
<>
<span className="text-4xl font-bold tabular-nums">
{displayLessonPct}
<span className="text-xl">%</span>
</span>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
{TOTAL_LESSONS - lessons} lessons left
</span>
</>
)}
</Ring>
</div>
<div className="mt-5 flex gap-2.5">
<button
type="button"
onClick={completeLesson}
disabled={courseDone || loading}
aria-busy={loading}
className={`${btnBase} flex-1 bg-violet-600 px-4 py-2.5 text-white shadow-sm hover:bg-violet-500 focus-visible:ring-violet-500`}
>
{loading ? (
<>
<Spinner /> Saving…
</>
) : courseDone ? (
"Course complete"
) : (
<>
<CheckSmallIcon /> Complete next lesson
</>
)}
</button>
<button
type="button"
onClick={() => {
setLessons(0);
setLoading(false);
if (timer.current) clearTimeout(timer.current);
}}
aria-label="Reset course progress"
className={`${btnBase} border border-slate-300 bg-white px-3 py-2.5 text-slate-500 hover:border-slate-400 hover:text-slate-800 focus-visible:ring-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:text-slate-100`}
>
<ResetIcon />
</button>
</div>
</article>
</div>
</div>
</section>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
/>
</svg>
);
}
function ResetIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M3 12a9 9 0 1 0 3-6.7M3 4v4h4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CheckSmallIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M20 6 9 17l-5-5"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CloudIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-7 w-7 text-sky-500 dark:text-sky-400"
aria-hidden="true"
>
<path
d="M7 18a4 4 0 0 1-.5-7.97 5.5 5.5 0 0 1 10.6-1.06A4.25 4.25 0 0 1 16.75 18H7Z"
stroke="currentColor"
strokeWidth={1.8}
strokeLinejoin="round"
/>
</svg>
);
}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.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

