Ring Gradient Progress
Original · freegradient circular progress
byWeb InnoventixReact + Tailwind
progringgradientprogress
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/prog-ring-gradient.jsonprog-ring-gradient.tsx
"use client";
import { useEffect, useState } from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
type Phase = { pct: number; label: string; note: string };
const PHASES: Phase[] = [
{ pct: 0, label: "Kickoff", note: "Scope locked, team briefed" },
{ pct: 25, label: "Design", note: "Flows & UI signed off" },
{ pct: 50, label: "Build", note: "Core features in code review" },
{ pct: 75, label: "Review", note: "QA sweep & stakeholder demo" },
{ pct: 100, label: "Launch", note: "Shipped to production" },
];
const SIZE = 220;
const STROKE = 16;
const R = (SIZE - STROKE) / 2 - 6;
const CENTER = SIZE / 2;
const C = 2 * Math.PI * R;
function clamp(n: number): number {
return Math.max(0, Math.min(100, Math.round(n)));
}
function dotAt(pct: number): { x: number; y: number } {
const angle = ((pct / 100) * 360 - 90) * (Math.PI / 180);
return { x: CENTER + R * Math.cos(angle), y: CENTER + R * Math.sin(angle) };
}
export default function ProgRingGradient() {
const reduce = useReducedMotion();
const [value, setValue] = useState<number>(68);
const [display, setDisplay] = useState<number>(68);
const spring = useSpring(68, { stiffness: 150, damping: 26, mass: 0.7 });
const dashOffset = useTransform(spring, (v: number) => C * (1 - v / 100));
useEffect(() => {
if (reduce) spring.jump(value);
else spring.set(value);
}, [value, reduce, spring]);
useEffect(() => {
const unsub = spring.on("change", (v: number) => setDisplay(Math.round(v)));
return () => unsub();
}, [spring]);
const phaseIndex = PHASES.reduce(
(acc, p, i) => (value >= p.pct ? i : acc),
0
);
const current = PHASES[phaseIndex];
const complete = value >= 100;
const remaining = 100 - value;
const step = (delta: number) => setValue((v) => clamp(v + delta));
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100 sm:py-24">
<style>{`
@keyframes prgRingHalo {
0% { transform: rotate(0deg); opacity: 0.55; }
50% { opacity: 0.9; }
100% { transform: rotate(360deg); opacity: 0.55; }
}
@keyframes prgRingPop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.prg-halo { animation: prgRingHalo 14s linear infinite; transform-origin: center; }
.prg-pop { animation: prgRingPop 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.prg-halo, .prg-pop { animation: none !important; }
}
`}</style>
<motion.div
initial={reduce ? false : { opacity: 0, y: 18 }}
whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto w-full max-w-xl rounded-3xl border border-slate-200 bg-white/80 p-7 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30 sm:p-9"
>
<header className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Release pipeline
</span>
<h2 className="mt-4 text-2xl font-bold tracking-tight sm:text-3xl">
Launch readiness
</h2>
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
Track the Q3 mobile release from kickoff to ship.
</p>
</header>
{/* Ring */}
<div className="flex flex-col items-center">
<div
className="relative"
style={{ width: SIZE, height: SIZE, maxWidth: "100%" }}
>
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className="h-full w-full -rotate-90"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${value}% complete — phase ${current.label}`}
>
<defs>
<linearGradient
id="prgRingGrad"
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2={SIZE}
y2={SIZE}
>
<stop offset="0%" stopColor="#6366f1" />
<stop offset="55%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#38bdf8" />
</linearGradient>
</defs>
{/* rotating halo */}
<g className="prg-halo" style={{ opacity: 0.5 }}>
<circle
cx={CENTER}
cy={CENTER}
r={R + STROKE / 2 + 3}
fill="none"
stroke="url(#prgRingGrad)"
strokeWidth={2}
strokeDasharray="2 10"
strokeLinecap="round"
/>
</g>
{/* track */}
<circle
cx={CENTER}
cy={CENTER}
r={R}
fill="none"
strokeWidth={STROKE}
className="stroke-slate-200 dark:stroke-slate-800"
/>
{/* progress */}
<motion.circle
cx={CENTER}
cy={CENTER}
r={R}
fill="none"
stroke="url(#prgRingGrad)"
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={{ strokeDashoffset: dashOffset }}
/>
{/* milestone markers */}
{PHASES.map((p) => {
const { x, y } = dotAt(p.pct);
const reached = value >= p.pct;
return (
<circle
key={p.pct}
cx={x}
cy={y}
r={reached ? 5 : 4}
className={
reached
? "fill-white stroke-emerald-500 dark:fill-slate-950"
: "fill-slate-100 stroke-slate-300 dark:fill-slate-900 dark:stroke-slate-700"
}
strokeWidth={2.5}
/>
);
})}
</svg>
{/* center readout */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<div className="flex items-start">
<span className="text-5xl font-bold tabular-nums tracking-tight sm:text-6xl">
{display}
</span>
<span className="mt-1.5 text-2xl font-semibold text-slate-400 dark:text-slate-500">
%
</span>
</div>
<div className="mt-1 flex items-center gap-1.5">
{complete ? (
<span
key="done"
className="prg-pop inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-sm font-semibold text-emerald-600 dark:text-emerald-400"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{current.label}
</span>
) : (
<span className="text-sm font-semibold text-slate-600 dark:text-slate-300">
{current.label}
</span>
)}
</div>
</div>
</div>
<p
aria-live="polite"
className="mt-4 text-center text-sm text-slate-500 dark:text-slate-400"
>
{complete ? current.note : `${current.note} · ${remaining}% to launch`}
</p>
</div>
{/* Slider + steppers */}
<div className="mt-8">
<div className="mb-2 flex items-center justify-between">
<label
htmlFor="prgRingSlider"
className="text-sm font-medium text-slate-700 dark:text-slate-200"
>
Adjust completion
</label>
<span className="text-sm font-semibold tabular-nums text-slate-500 dark:text-slate-400">
{value}%
</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => step(-1)}
disabled={value <= 0}
aria-label="Decrease completion by one percent"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-slate-300 text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
aria-hidden="true"
>
<path d="M5 12h14" />
</svg>
</button>
<input
id="prgRingSlider"
type="range"
min={0}
max={100}
step={1}
value={value}
onChange={(e) => setValue(clamp(Number(e.target.value)))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-800 dark:accent-violet-400 dark:focus-visible:ring-offset-slate-900"
/>
<button
type="button"
onClick={() => step(1)}
disabled={value >= 100}
aria-label="Increase completion by one percent"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-slate-300 text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
</div>
{/* Phase presets */}
<div
role="group"
aria-label="Jump to phase"
className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-5"
>
{PHASES.map((p, i) => {
const active = i === phaseIndex;
return (
<button
key={p.pct}
type="button"
onClick={() => setValue(p.pct)}
aria-pressed={active}
className={
"rounded-xl border px-2 py-2.5 text-center text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 " +
(active
? "border-transparent bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-white shadow-md shadow-indigo-500/20"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800")
}
>
<span className="block tabular-nums">{p.pct}%</span>
<span className="mt-0.5 block text-[11px] font-medium opacity-90">
{p.label}
</span>
</button>
);
})}
</div>
</motion.div>
</section>
);
}Dependencies
motion
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 →
Bar Basic Progress
Originalbasic progress bars in colours

Bar Striped Progress
Originalanimated striped progress bar

Bar Gradient Progress
Originalgradient progress bar with label

Bar Steps Progress
Originalstepped progress bar

Ring Progress
Originalcircular progress ring with percent

Semicircle Progress
Originalsemicircle gauge progress

Multi Progress
Originalmulti-segment stacked progress

Indeterminate Progress
Originalindeterminate progress bar

Label Inside Progress
Originalprogress bar with inner label

Upload Progress
Originalfile upload progress rows

Skill Bars Progress
Originalanimated skill/percent bars

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

