Skip to content
Plinth

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
Open in v0
'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>
    </>
  );
}

More in Loaders