Semicircle Progress
Original · freesemicircle gauge progress
byWeb InnoventixReact + Tailwind
progsemicircleprogress
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-semicircle.jsonprog-semicircle.tsx
"use client";
import { useEffect, useState } from "react";
import {
motion,
animate,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
} from "motion/react";
type Zone = "healthy" | "warm" | "critical";
const ZONES: Record<
Zone,
{ label: string; arcText: string; pillClass: string; sliderAccent: string }
> = {
healthy: {
label: "Comfortable headroom",
arcText: "text-emerald-500 dark:text-emerald-400",
pillClass:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
sliderAccent: "accent-emerald-500",
},
warm: {
label: "Running warm",
arcText: "text-amber-500 dark:text-amber-400",
pillClass:
"bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
sliderAccent: "accent-amber-500",
},
critical: {
label: "Near saturation",
arcText: "text-rose-500 dark:text-rose-400",
pillClass:
"bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
sliderAccent: "accent-rose-500",
},
};
const PRESETS: { label: string; v: number }[] = [
{ label: "Idle", v: 24 },
{ label: "Steady", v: 52 },
{ label: "Peak hour", v: 78 },
{ label: "Incident", v: 94 },
];
const CX = 160;
const CY = 150;
const R = 120;
const ARC = `M ${CX - R} ${CY} A ${R} ${R} 0 0 1 ${CX + R} ${CY}`;
function clamp(n: number): number {
return Math.max(0, Math.min(100, n));
}
function zoneOf(v: number): Zone {
return v < 60 ? "healthy" : v < 85 ? "warm" : "critical";
}
function polar(v: number): { x: number; y: number } {
const t = clamp(v) / 100;
const a = Math.PI - t * Math.PI;
return { x: CX + R * Math.cos(a), y: CY - R * Math.sin(a) };
}
function radial(
v: number,
r1: number,
r2: number,
): { x1: number; y1: number; x2: number; y2: number } {
const t = clamp(v) / 100;
const a = Math.PI - t * Math.PI;
return {
x1: CX + r1 * Math.cos(a),
y1: CY - r1 * Math.sin(a),
x2: CX + r2 * Math.cos(a),
y2: CY - r2 * Math.sin(a),
};
}
export default function ProgSemicircle() {
const reduce = !!useReducedMotion();
const [value, setValue] = useState(52);
const [display, setDisplay] = useState(0);
const p = useMotionValue(0);
useMotionValueEvent(p, "change", (latest) => setDisplay(latest));
useEffect(() => {
if (reduce) {
p.set(value);
return;
}
const controls = animate(p, value, {
duration: 0.85,
ease: [0.22, 1, 0.36, 1],
});
return () => controls.stop();
}, [value, reduce, p]);
const shown = clamp(display);
const zone = zoneOf(shown);
const meta = ZONES[zone];
const dot = polar(shown);
const big = Math.round(shown);
const nodes = value < 60 ? 6 : value < 85 ? 8 : 10;
const rps = Math.round(value * 41.6);
const p95 = Math.round(46 + Math.pow(value / 100, 3) * 680);
const t60 = radial(60, R - 11, R + 11);
const t85 = radial(85, R - 11, R + 11);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes psg-halo { 0%,100%{ opacity:.28; transform:scale(1); } 50%{ opacity:.06; transform:scale(2); } }
@keyframes psg-live { 0%{ opacity:.9; transform:scale(1); } 70%,100%{ opacity:0; transform:scale(2.6); } }
.psg-halo{ transform-box:fill-box; transform-origin:center; animation:psg-halo 2.4s ease-in-out infinite; }
.psg-live{ transform-origin:center; animation:psg-live 1.8s ease-out infinite; }
@media (prefers-reduced-motion: reduce){ .psg-halo,.psg-live{ animation:none !important; } }
`}</style>
<div className="mx-auto max-w-2xl">
<div className="mb-8 text-center sm:mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
Live infrastructure
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Cluster utilization
</h2>
<p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
Compute pressure across the us-east-2 node pool, sampled every 30
seconds.
</p>
</div>
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-9 dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs font-medium text-slate-500 dark:text-slate-400">
<span className="relative flex h-2 w-2">
<span className="psg-live absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
<span>us-east-2 · node pool</span>
</div>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${meta.pillClass}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{meta.label}
</span>
</div>
<p className="sr-only" aria-live="polite">
Cluster utilization {value} percent. {ZONES[zoneOf(value)].label}.
</p>
<div className="relative mx-auto mt-4 max-w-md">
<svg
viewBox="0 0 320 176"
className="h-auto w-full overflow-visible"
role="img"
aria-label={`Semicircle gauge showing ${value}% cluster utilization`}
>
<path
d={ARC}
fill="none"
strokeWidth={18}
strokeLinecap="round"
stroke="currentColor"
className="text-slate-200 dark:text-slate-800"
/>
<path
d={ARC}
fill="none"
strokeWidth={18}
strokeLinecap="round"
stroke="currentColor"
pathLength={100}
strokeDasharray={100}
strokeDashoffset={100 - shown}
className={meta.arcText}
/>
<g
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
className="text-white dark:text-slate-900"
>
<line x1={t60.x1} y1={t60.y1} x2={t60.x2} y2={t60.y2} />
<line x1={t85.x1} y1={t85.y1} x2={t85.x2} y2={t85.y2} />
</g>
<g className={meta.arcText}>
<circle
className="psg-halo"
cx={dot.x}
cy={dot.y}
r={9}
fill="currentColor"
/>
<circle
cx={dot.x}
cy={dot.y}
r={9}
fill="currentColor"
className="text-white dark:text-slate-900"
/>
<circle cx={dot.x} cy={dot.y} r={6} fill="currentColor" />
</g>
<text
x={40}
y={172}
textAnchor="middle"
fontSize={11}
fontWeight={600}
className="fill-slate-400 dark:fill-slate-500"
>
0
</text>
<text
x={280}
y={172}
textAnchor="middle"
fontSize={11}
fontWeight={600}
className="fill-slate-400 dark:fill-slate-500"
>
100
</text>
</svg>
<div className="pointer-events-none absolute left-1/2 top-[62%] -translate-x-1/2 -translate-y-1/2 text-center">
<div className="flex items-start justify-center">
<span className="text-5xl font-semibold tabular-nums tracking-tight text-slate-900 sm:text-6xl dark:text-white">
{big}
</span>
<span className="mt-1 text-2xl font-semibold text-slate-400 sm:mt-1.5 dark:text-slate-500">
%
</span>
</div>
<p className="mt-1 text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
of provisioned vCPU
</p>
</div>
</div>
<dl className="mt-6 grid grid-cols-3 gap-px overflow-hidden rounded-2xl border border-slate-200 bg-slate-200 dark:border-slate-800 dark:bg-slate-800">
<div className="bg-white p-3 sm:p-4 dark:bg-slate-900">
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Active nodes
</dt>
<dd className="mt-1 text-lg font-semibold tabular-nums text-slate-900 dark:text-white">
{nodes}
</dd>
</div>
<div className="bg-white p-3 sm:p-4 dark:bg-slate-900">
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Requests / s
</dt>
<dd className="mt-1 text-lg font-semibold tabular-nums text-slate-900 dark:text-white">
{rps.toLocaleString()}
</dd>
</div>
<div className="bg-white p-3 sm:p-4 dark:bg-slate-900">
<dt className="text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
p95 latency
</dt>
<dd className="mt-1 text-lg font-semibold tabular-nums text-slate-900 dark:text-white">
{p95} ms
</dd>
</div>
</dl>
<div className="mt-8 space-y-5">
<div>
<div className="mb-2.5 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Scenarios
</span>
<button
type="button"
onClick={() => setValue(52)}
className="rounded text-xs font-medium text-slate-500 underline-offset-4 transition hover:text-slate-800 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-slate-200 dark:focus-visible:ring-slate-500 dark:focus-visible:ring-offset-slate-900"
>
Reset
</button>
</div>
<div
role="group"
aria-label="Load scenarios"
className="flex flex-wrap gap-2"
>
{PRESETS.map((preset) => {
const active = value === preset.v;
return (
<button
key={preset.label}
type="button"
aria-pressed={active}
onClick={() => setValue(preset.v)}
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-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-slate-500 dark:focus-visible:ring-offset-slate-900 ${
active
? "border border-transparent bg-slate-900 text-white dark:bg-white dark:text-slate-900"
: "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
}`}
>
{preset.label}
</button>
);
})}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label
htmlFor="psg-load"
className="text-xs font-medium text-slate-600 dark:text-slate-400"
>
Simulated load
</label>
<span className="text-xs font-semibold tabular-nums text-slate-700 dark:text-slate-300">
{value}%
</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setValue((v) => clamp(v - 5))}
disabled={value <= 0}
aria-label="Decrease load by 5 percent"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-300 bg-white text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 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-slate-500 dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 20 20" className="h-5 w-5" aria-hidden="true">
<path
d="M5 10h10"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
/>
</svg>
</button>
<input
id="psg-load"
type="range"
min={0}
max={100}
step={1}
value={value}
onChange={(e) => setValue(Number(e.currentTarget.value))}
aria-label="Set cluster utilization percentage"
className={`h-2 flex-1 cursor-pointer rounded ${meta.sliderAccent} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-slate-500 dark:focus-visible:ring-offset-slate-900`}
/>
<button
type="button"
onClick={() => setValue((v) => clamp(v + 5))}
disabled={value >= 100}
aria-label="Increase load by 5 percent"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-300 bg-white text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 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-slate-500 dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 20 20" className="h-5 w-5" aria-hidden="true">
<path
d="M10 5v10M5 10h10"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
</div>
<div className="mt-6 flex items-start gap-2 rounded-xl bg-slate-50 p-3 text-xs text-slate-500 dark:bg-slate-800/60 dark:text-slate-400">
<svg
viewBox="0 0 20 20"
className="mt-0.5 h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500"
aria-hidden="true"
>
<path
d="M11 2 4 11h5l-1 7 7-9h-5l1-7Z"
fill="currentColor"
/>
</svg>
<p>
Autoscaling triggers at{" "}
<span className="font-semibold text-slate-700 dark:text-slate-200">
85%
</span>
. New nodes join the pool in ~40s; current p95 latency is {p95} ms.
</p>
</div>
</motion.div>
</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

Ring Gradient Progress
Originalgradient circular 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.

