Ring Progress
Original · freecircular progress ring with percent
byWeb InnoventixReact + Tailwind
progringprogress
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.jsonprog-ring.tsx
"use client";
import { useEffect, useId, useState } from "react";
import {
motion,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
const GOAL_HOURS = 30;
const STEP = 0.5;
const RADIUS = 96;
const CIRC = 2 * Math.PI * RADIUS;
const TAU = Math.PI * 2;
type Preset = { label: string; hint: string; hours: number };
const PRESETS: Preset[] = [
{ label: "Reset", hint: "0h", hours: 0 },
{ label: "Mid-week", hint: "15h", hours: 15 },
{ label: "Goal met", hint: "30h", hours: 30 },
];
type Tier = { label: string; dot: string };
function tierFor(pct: number): Tier {
if (pct <= 0) return { label: "No focus logged yet", dot: "bg-slate-300 dark:bg-slate-600" };
if (pct < 34) return { label: "Warming up", dot: "bg-amber-400" };
if (pct < 67) return { label: "Building momentum", dot: "bg-sky-400" };
if (pct < 100) return { label: "In the zone", dot: "bg-indigo-500" };
return { label: "Weekly goal complete", dot: "bg-emerald-500" };
}
function clampHours(v: number): number {
if (Number.isNaN(v)) return 0;
return Math.min(GOAL_HOURS, Math.max(0, v));
}
export default function ProgressRing() {
const reduce = useReducedMotion();
const rawId = useId();
const gid = `pring-${rawId.replace(/:/g, "")}`;
const sliderId = `${gid}-slider`;
const [hours, setHours] = useState<number>(18);
const [display, setDisplay] = useState<number>(0);
const pct = (hours / GOAL_HOURS) * 100;
const roundedTarget = Math.round(pct);
const complete = hours >= GOAL_HOURS;
const tier = tierFor(pct);
const remaining = GOAL_HOURS - hours;
const target = useMotionValue(0);
const spring = useSpring(target, { stiffness: 120, damping: 20, mass: 0.7 });
const live = reduce ? target : spring;
const dashOffset = useTransform(live, (v) => {
const f = Math.min(100, Math.max(0, v)) / 100;
return CIRC * (1 - f);
});
const tipX = useTransform(live, (v) => 120 + RADIUS * Math.sin((Math.min(100, Math.max(0, v)) / 100) * TAU));
const tipY = useTransform(live, (v) => 120 - RADIUS * Math.cos((Math.min(100, Math.max(0, v)) / 100) * TAU));
useMotionValueEvent(live, "change", (v) => {
setDisplay(Math.round(v));
});
useEffect(() => {
target.set(pct);
}, [pct, target]);
function step(delta: number): void {
setHours((h) => clampHours(Math.round((h + delta) * 2) / 2));
}
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes prog-ring-aura {
0%, 100% { opacity: .35; transform: scale(1); }
50% { opacity: .7; transform: scale(1.04); }
}
@keyframes prog-ring-pop {
0% { transform: scale(1); }
45% { transform: scale(1.06); }
100% { transform: scale(1); }
}
.prog-ring-aura { animation: prog-ring-aura 4s ease-in-out infinite; }
.prog-ring-pop { animation: prog-ring-pop .6s cubic-bezier(.22,1,.36,1); }
@media (prefers-reduced-motion: reduce) {
.prog-ring-aura, .prog-ring-pop { animation: none !important; }
}
`}</style>
<div
aria-hidden
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl"
/>
<div className="relative mx-auto max-w-3xl">
<header className="mb-10 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Focus tracker
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Deep work — this week</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Drag the dial or nudge the counter to log the focus hours you've banked toward a 30-hour weekly target.
</p>
</header>
<div className="grid items-center gap-10 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-10 md:grid-cols-2 dark:border-slate-800 dark:bg-slate-900">
{/* Ring */}
<div className="relative mx-auto flex aspect-square w-56 items-center justify-center sm:w-64">
<div
aria-hidden
className="prog-ring-aura absolute inset-4 rounded-full bg-gradient-to-tr from-indigo-500/25 via-violet-500/25 to-sky-400/25 blur-2xl"
/>
<svg
viewBox="0 0 240 240"
className="relative h-full w-full"
role="img"
aria-label={`Progress ring showing ${roundedTarget}% of the weekly focus goal`}
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6366f1" />
<stop offset="55%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#38bdf8" />
</linearGradient>
</defs>
<circle
cx={120}
cy={120}
r={RADIUS}
fill="none"
strokeWidth={16}
stroke="currentColor"
className="text-slate-200 dark:text-slate-800"
/>
<motion.circle
cx={120}
cy={120}
r={RADIUS}
fill="none"
strokeWidth={16}
strokeLinecap="round"
stroke={`url(#${gid})`}
strokeDasharray={CIRC}
strokeDashoffset={dashOffset}
transform="rotate(-90 120 120)"
/>
<motion.circle
cx={tipX}
cy={tipY}
r={7}
strokeWidth={4}
stroke={`url(#${gid})`}
className="fill-white dark:fill-slate-950"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<div className={complete ? "prog-ring-pop" : undefined}>
<span className="text-5xl font-semibold tabular-nums tracking-tight text-slate-900 dark:text-white">
{display}
<span className="align-top text-2xl font-medium text-slate-400 dark:text-slate-500">%</span>
</span>
</div>
<p className="mt-1 text-sm font-medium tabular-nums text-slate-500 dark:text-slate-400">
{hours.toFixed(1)} / {GOAL_HOURS} hrs
</p>
</div>
</div>
{/* Controls */}
<div>
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
<span aria-hidden className={`h-2 w-2 rounded-full ${tier.dot}`} />
{tier.label}
</div>
<div className="mt-6">
<label
htmlFor={sliderId}
className="flex items-baseline justify-between text-sm font-medium text-slate-600 dark:text-slate-300"
>
<span>Focus hours logged</span>
<span className="tabular-nums text-slate-400 dark:text-slate-500">{hours.toFixed(1)}h</span>
</label>
<input
id={sliderId}
type="range"
min={0}
max={GOAL_HOURS}
step={STEP}
value={hours}
onChange={(e) => setHours(clampHours(parseFloat(e.target.value)))}
aria-valuetext={`${hours.toFixed(1)} of ${GOAL_HOURS} hours`}
className="mt-3 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-700 dark:focus-visible:ring-offset-slate-900"
/>
</div>
<div className="mt-6 flex items-center gap-3">
<button
type="button"
onClick={() => step(-STEP)}
disabled={hours <= 0}
aria-label="Log 30 fewer minutes"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 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:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M5 10h10" strokeLinecap="round" />
</svg>
</button>
<div className="flex-1 rounded-xl border border-slate-200 bg-slate-50 py-2 text-center dark:border-slate-700 dark:bg-slate-800/60">
<div className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">Logged</div>
<div className="text-lg font-semibold tabular-nums text-slate-900 dark:text-white">{hours.toFixed(1)}h</div>
</div>
<button
type="button"
onClick={() => step(STEP)}
disabled={hours >= GOAL_HOURS}
aria-label="Log 30 more minutes"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 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:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d="M10 5v10M5 10h10" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="mt-6">
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
Quick set
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => {
const active = hours === p.hours;
return (
<button
key={p.label}
type="button"
onClick={() => setHours(p.hours)}
aria-pressed={active}
className={
"rounded-full px-3.5 py-1.5 text-sm font-medium 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
? "bg-indigo-600 text-white shadow-sm"
: "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700")
}
>
{p.label}
<span className={active ? "ml-1.5 text-indigo-200" : "ml-1.5 text-slate-400 dark:text-slate-500"}>
{p.hint}
</span>
</button>
);
})}
</div>
</div>
<p className="mt-6 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
{complete ? (
<>
<svg viewBox="0 0 20 20" className="h-4 w-4 text-emerald-500" fill="currentColor" aria-hidden>
<path
fillRule="evenodd"
d="M16.7 5.3a1 1 0 0 1 0 1.4l-7.5 7.5a1 1 0 0 1-1.4 0L3.3 10.7a1 1 0 1 1 1.4-1.4l3.1 3.1 6.8-6.8a1 1 0 0 1 1.4 0Z"
clipRule="evenodd"
/>
</svg>
Goal reached — nice work this week.
</>
) : (
<>
<span aria-hidden className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
<span className="tabular-nums font-medium text-slate-700 dark:text-slate-200">{remaining.toFixed(1)}h</span>
left to hit your weekly target.
</>
)}
</p>
</div>
</div>
<p aria-live="polite" className="sr-only">
{roundedTarget} percent of weekly focus goal reached, {hours.toFixed(1)} of {GOAL_HOURS} hours logged.
</p>
</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 Gradient Progress
Originalgradient circular progress

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.

