EmpireUI
Get Pro
← Blog8 min read#neumorphism#progress#ring

Neumorphism Progress Ring: Soft UI Circular Indicator in CSS

Build a soft UI progress ring with pure CSS neumorphism — concave shadows, SVG stroke-dasharray animation, and zero JavaScript required for the core effect.

Soft neumorphic circular progress ring on light grey background

Why a Circular Progress Indicator is Hard to Get Right

Progress rings sound simple. Draw a circle, fill part of it, done. In practice the gap between a boring <progress> element and something that actually looks polished is huge — and when you're targeting a neumorphism aesthetic, the bar gets even higher. The whole style lives or dies on depth cues. Flat just doesn't cut it.

Neumorphism (sometimes called soft UI) uses paired box-shadow values — one light, one dark — to push and pull surfaces out of a shared background. Applied to a circular indicator, you want the track to look *recessed*, like a groove pressed into clay, while the fill arc rides on top. Honestly, most tutorials skip the recessed-track part entirely and just slap a coloured arc on a grey circle. The result is fine but it's not really neumorphism.

The technique we're building here appeared on Dribbble in 2019 and has been refined over the years. By 2026 it's totally viable in production — browser support for the CSS and SVG properties involved is effectively universal. So let's actually do it right.

The Visual Recipe: Shadows, SVG, and a Shared Background Color

Neumorphism demands one non-negotiable rule: your element background and your page background must share the same colour. The shadows only read as depth when there's no sudden colour boundary to break the illusion. For a progress ring that means your outer ring container, the track, and the body all need the same hex — something like #e0e5ec is the classic choice.

The ring itself is an <svg> with two <circle> elements. The first is the track — full circumference, no progress fill, just a visual groove. The second is the indicator arc, animated with stroke-dasharray and stroke-dashoffset. You wrap that SVG in a <div> that carries the neumorphic box-shadow. That outer wrapper is what creates the recessed pit effect; the SVG sits inside it and the coloured arc floats over the track.

Worth noting: box-shadow doesn't work on SVG elements directly in all browsers. Wrapping the SVG in a <div> and applying the shadow there sidesteps the issue cleanly. One more thing — the inner shadow (the recessed look) requires inset in your box-shadow declaration, which is the detail that separates a real soft-UI ring from a flat one wearing a grey costume.

Quick aside: if you're already using Empire UI's neumorphism components, several of the base tokens — the background colour, shadow pairs, and border-radius scale — map directly onto the values below. Copy them verbatim instead of hand-tuning.

Building the HTML and CSS Structure

Here's the base markup. Nothing exotic — a container div, the SVG, and a text label in the centre for the percentage readout.

<div class="neu-ring-wrapper">
  <svg class="neu-ring" viewBox="0 0 120 120" width="120" height="120">
    <!-- track -->
    <circle
      class="neu-ring__track"
      cx="60" cy="60" r="50"
      fill="none"
      stroke="#cdd3dc"
      stroke-width="10"
    />
    <!-- progress arc -->
    <circle
      class="neu-ring__arc"
      cx="60" cy="60" r="50"
      fill="none"
      stroke="#6c63ff"
      stroke-width="10"
      stroke-linecap="round"
      stroke-dasharray="314"
      stroke-dashoffset="94"
    />
  </svg>
  <span class="neu-ring__label">70%</span>
</div>

The radius is 50px, so the circumference is 2 × π × 50 ≈ 314px. To show 70% progress, stroke-dashoffset = 314 × (1 - 0.70) = 94.2. That's all the maths you need. Now the CSS:

:root {
  --bg: #e0e5ec;
  --shadow-light: #ffffff;
  --shadow-dark: #a3b1c6;
  --accent: #6c63ff;
}

body {
  background: var(--bg);
  display: grid;
  place-items: center;
  min-height: 100vh;
  margin: 0;
}

.neu-ring-wrapper {
  position: relative;
  width: 120px;
  height: 120px;
  border-radius: 50%;
  background: var(--bg);
  /* recessed pit — the key neumorphic move */
  box-shadow:
    inset 6px 6px 12px var(--shadow-dark),
    inset -6px -6px 12px var(--shadow-light);
  display: flex;
  align-items: center;
  justify-content: center;
}

.neu-ring {
  position: absolute;
  top: 0;
  left: 0;
  /* start arc at 12 o'clock, not 3 o'clock */
  transform: rotate(-90deg);
}

.neu-ring__track {
  opacity: 0.4;
}

.neu-ring__label {
  position: relative; /* above SVG */
  font-family: system-ui, sans-serif;
  font-size: 1.25rem;
  font-weight: 700;
  color: #4a5568;
}

The inset shadows are doing the heavy lifting. Without them you'd just have a coloured circle on a grey background. With them the whole container recedes into the surface, making the arc look like it's rising out of a groove. Adjust the 6px offset and 12px blur to taste — tighter values (4px / 8px) feel more subtle, wider values (8px / 20px) get dramatic fast.

Animating the Arc with CSS Transitions

The pure CSS animation approach uses a @keyframes rule to drive stroke-dashoffset from the full circumference (314px — no progress) down to your target value. No JavaScript. No requestAnimationFrame. It just works.

@keyframes ring-fill {
  from {
    stroke-dashoffset: 314;
  }
  to {
    stroke-dashoffset: 94; /* 70% */
  }
}

.neu-ring__arc {
  stroke-dasharray: 314;
  stroke-dashoffset: 314;
  animation: ring-fill 1.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}

The cubic-bezier(0.4, 0, 0.2, 1) curve — Google's Material easing — gives the fill a natural deceleration. It eases in slightly, glides, and settles. If you want something bouncier, try cubic-bezier(0.34, 1.56, 0.64, 1) for a mild spring overshoot. Don't go overboard with spring physics on a progress indicator though — it implies uncertainty about the final value, which is the opposite of what you want.

In practice, you'll drive the stroke-dashoffset dynamically. In a React component, calculate it from a value prop and set it as an inline style or a CSS custom property:

interface NeuRingProps {
  value: number; // 0-100
  size?: number;
  strokeWidth?: number;
}

export function NeuProgressRing({
  value,
  size = 120,
  strokeWidth = 10,
}: NeuRingProps) {
  const r = (size - strokeWidth) / 2;
  const circ = 2 * Math.PI * r;
  const offset = circ * (1 - value / 100);

  return (
    <div
      className="neu-ring-wrapper"
      style={{ width: size, height: size }}
    >
      <svg
        viewBox={`0 0 ${size} ${size}`}
        width={size}
        height={size}
        style={{ position: 'absolute', top: 0, left: 0, transform: 'rotate(-90deg)' }}
      >
        <circle
          cx={size / 2} cy={size / 2} r={r}
          fill="none" stroke="#cdd3dc" strokeWidth={strokeWidth}
          opacity={0.4}
        />
        <circle
          cx={size / 2} cy={size / 2} r={r}
          fill="none" stroke="#6c63ff" strokeWidth={strokeWidth}
          strokeLinecap="round"
          strokeDasharray={circ}
          strokeDashoffset={offset}
          style={{ transition: 'stroke-dashoffset 1.4s cubic-bezier(0.4,0,0.2,1)' }}
        />
      </svg>
      <span className="neu-ring__label">{Math.round(value)}%</span>
    </div>
  );
}

Now you can drive it reactively — pass a live percentage from an API, a file upload handler, whatever. The transition fires automatically on offset change. Size it at 80px for a compact dashboard widget or 200px for a hero stat and the maths stays correct because everything derives from size and strokeWidth.

Adding Colour Variants and Gradient Strokes

Single-colour arcs look fine. Gradient arcs look exceptional. SVG strokes can't directly accept CSS background: linear-gradient() but you can fake it with a <linearGradient> defined in a <defs> block and referenced via stroke="url(#grad)".

<defs>
  <linearGradient id="neuGrad" x1="0%" y1="0%" x2="100%" y2="0%">
    <stop offset="0%" stopColor="#6c63ff" />
    <stop offset="100%" stopColor="#f093fb" />
  </linearGradient>
</defs>
<circle
  // ... same props as before
  stroke="url(#neuGrad)"
/>

That purple-to-pink gradient is borrowed from Empire UI's vaporwave palette — check the vaporwave style hub if you want more of those combinations. For a more grounded colour story, the gradient generator will spit out the exact hex stops you need for any two-colour transition.

Look, there's a real gotcha with SVG gradients: linearGradient coordinates are relative to the SVG viewport by default (not the element), so a circle will get a gradient that cuts differently depending on its position in the SVG. If the ring isn't centred in the viewport, add gradientUnits="userSpaceOnUse" and set x1, y1, x2, y2 to absolute coordinates matching your circle's bounds. For a 120×120 SVG with the ring centred, that's x1="10" y1="60" x2="110" y2="60".

That said, if you need multiple rings on the same page — a dashboard with CPU, memory, and disk — scope the gradient ID per instance or you'll get conflicts. In React, generate a unique ID with useId() (available since React 18) and reference it locally.

Neumorphism + Dark Mode: Making It Work

Neumorphism has a reputation for failing in dark mode. The criticism is fair for naive implementations. The style *does* depend on a mid-tone background — too light and the dark shadow vanishes, too dark and the light shadow vanishes. But you're not stuck with grey-on-grey forever.

The solution is to pick a dark mid-tone and recalibrate both shadows. Background #1e2330 with a light shadow at rgba(255,255,255,0.07) and a dark shadow at rgba(0,0,0,0.5) gives you a dark-mode soft UI that actually reads correctly. The arc colour needs more saturation in dark mode — bump the hsl lightness up about 10–15% compared to your light-mode accent.

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1e2330;
    --shadow-light: rgba(255, 255, 255, 0.07);
    --shadow-dark: rgba(0, 0, 0, 0.5);
    --accent: #8b83ff; /* lighter violet for dark bg */
  }

  .neu-ring__label {
    color: #c3c9d9;
  }

  .neu-ring__track {
    stroke: #2d3447;
  }
}

Honestly, dark-mode neumorphism ends up looking a bit like cyberpunk without the neon overload — if that aesthetic appeals to you, go further and swap the accent to a hot cyan or electric green. The cyberpunk style hub has ready-made colour tokens to steal from. Mixing soft-UI shadows with a high-contrast accent is a legitimately striking combo.

One accessibility note: the recessed effect on dark backgrounds can lose its legibility at 1x on lower-contrast displays. Test on an actual device, not just your MacBook Pro with its HDR screen. The label percentage text inside the ring is what carries the most functional information — keep it at or above 4.5:1 contrast against the ring background.

When to Reach for a Progress Ring vs. a Progress Bar

Progress rings earn their place when you're showing a single, bounded metric in a compact space — storage quota, skill score, onboarding completion, upload percentage. The circular format packs a lot of visual information into a small footprint, and it reads as a unit even when surrounded by other UI.

Progress bars are better for sequential, multi-step flows where left-to-right spatial metaphor maps onto time or sequence. Checkout flows, install wizards, form validation chains. The linear form communicates directionality in a way a ring doesn't.

In practice, you'd often use both. A dashboard might have three rings across the top — quick-glance KPIs — and a bar down the side for a multi-stage pipeline. Check out the existing Tailwind progress bar article on the Empire UI blog for the linear counterpart to what we've built here, and see how you can token-share the colour palette between both.

If this ring is going inside an Empire UI template, you can drop the NeuProgressRing component directly into any of the dashboard starters. The neumorphism design tokens — particularly the --bg, shadow pair, and accent colours — are already defined in those templates' CSS layers, so you'd only need to adjust the --accent variable to match your brand. Browse the neumorphism component set to see what else you can combine it with.

FAQ

What CSS property creates the recessed neumorphic look on a progress ring?

The inset keyword in box-shadow does it — e.g. box-shadow: inset 6px 6px 12px #a3b1c6, inset -6px -6px 12px #ffffff. Both shadows need to share the same background colour as the page for the depth illusion to hold.

How do I calculate stroke-dashoffset for a given percentage?

Circumference is 2 × π × radius. Dashoffset equals circumference × (1 - value / 100). For a 50px radius circle that's 314 × (1 - percentage / 100) — wire the result straight into the SVG circle's stroke-dashoffset attribute.

Can I animate the progress ring without JavaScript?

Yes. Set stroke-dashoffset to the full circumference in your CSS and use a @keyframes animation to drive it to the target value with animation-fill-mode: forwards. You lose dynamic updates but for static displays it's zero-JS.

Does neumorphism work in dark mode?

It does, but you need a dark mid-tone background (e.g. #1e2330) and recalibrated shadow values — a very faint light shadow and a deeper dark shadow. Avoid pure black; it kills the depth cues the style depends on.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Neumorphism Keyboard UI: Soft Key Caps and Press Animation in CSSWhat Is Neumorphism? Soft UI Explained with Free React CodeNeumorphism in Tailwind CSS: Soft Shadows Without the Opacity TrapCSS Scroll Snap: Precise Scrolling Sections Without JavaScript