Original by Plinth · MIT
Loaders
Progress Ring
A masked conic-gradient arc with a live readout, driven by one custom property — no SVG, no dasharray maths.
#loader#progress#conic-gradient#mask#range
Install
npx shadcn@latest add https://plinthui.com/r/progress-ring.json'use client';
import { useState } from 'react';
// The conic sweep and the radial mask that punches the ring out are outside
// what Tailwind can express, so the material lives in the style element below.
const css = `
.ring {
/* --value (0-100) is the single source of truth: the sweep, the readout and
the slider all read from it. */
--size: clamp(96px, 26vw, 148px);
--thickness: 11px;
position: relative;
display: grid;
place-items: center;
width: var(--size);
height: var(--size);
}
/* A conic gradient with a hard stop is the arc; a radial mask punches out the
middle. No SVG, no stroke-dasharray arithmetic — and because it is a mask
rather than a cover disc, the centre stays transparent over any background. */
.ring__dial {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: 50%;
background:
conic-gradient(
from -90deg,
#ffca7a 0deg,
#ff7a9c calc(var(--value) * 1.8deg),
rgba(255, 255, 255, 0.09) calc(var(--value) * 3.6deg),
rgba(255, 255, 255, 0.09) 360deg
);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - var(--thickness)), #000 calc(100% - var(--thickness)));
mask: radial-gradient(farthest-side, transparent calc(100% - var(--thickness)), #000 calc(100% - var(--thickness)));
transition: background 120ms linear;
}
/* Outside the masked element — anything inside gets punched out with the middle. */
.ring__readout {
position: absolute;
font: 600 clamp(19px, 5vw, 27px)/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: -0.02em;
color: #f3f0e9;
/* Tabular, or the number jitters as digit widths change while dragging. */
font-variant-numeric: tabular-nums;
}
.ring__readout i {
font-style: normal;
font-size: 0.55em;
color: rgba(243, 240, 233, 0.55);
margin-left: 1px;
}
.ring__slider {
-webkit-appearance: none;
appearance: none;
width: clamp(140px, 42vw, 210px);
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
cursor: pointer;
}
.ring__slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 15px; height: 15px;
border-radius: 50%;
background: #ffca7a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
cursor: grab;
}
.ring__slider::-moz-range-thumb {
width: 15px; height: 15px;
border: 0;
border-radius: 50%;
background: #ffca7a;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
cursor: grab;
}
.ring__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.ring__dial { transition: none; }
}
`;
export function ProgressRing({ defaultValue = 68 }: { defaultValue?: number }) {
const [value, setValue] = useState(defaultValue);
return (
<>
<style>{css}</style>
<div
style={{
display: 'grid',
placeItems: 'center',
alignContent: 'center',
gap: 22,
minHeight: 260,
padding: 24,
background: '#0c0d11',
}}
>
<div className="ring" style={{ '--value': value } as React.CSSProperties}>
{/* The dial is a picture of the value, so it carries the label; the
slider below is the control. */}
<div className="ring__dial" role="img" aria-label={`${value} percent complete`} />
<span className="ring__readout">
{value}
<i>%</i>
</span>
</div>
<label>
<span className="ring__sr">Progress</span>
<input
className="ring__slider"
type="range"
min={0}
max={100}
value={value}
onChange={e => setValue(Number(e.target.value))}
/>
</label>
</div>
</>
);
}