Circular Progress Ring in React and Tailwind

6 min read

TL;DR Draw a faint track circle and a progress circle, set strokeDasharray to the circumference and strokeDashoffset to circumference times (1 minus value/100), and rotate the SVG -90 degrees so it starts at the top. Start the value at 0 and set it on the next frame so a CSS transition fills it. Hide the round cap at zero, put role progressbar on a wrapper and turn the transition off under reduced motion.

On this page
  1. How a progress ring works
  2. The component
  3. Why the ring starts empty
  4. The dot at zero
  5. Screen readers
  6. The number in the middle
  7. Reduced motion
  8. Using it
  9. Mistakes AI tools make with progress rings
  10. A prompt for Cursor, v0 or Lovable
  11. Rings, charts and checkmarks
  12. FAQ
  13. How do I make a circular progress bar in React without a library?
  14. Why does my SVG progress ring start at three o'clock?
  15. Why does my empty progress ring show a dot?
  16. How do I make a progress ring accessible?

Almost every dashboard Cursor, v0 or Lovable builds for you ends up with a circular progress ring somewhere: a monthly goal, storage used, onboarding steps done. And the generated version usually has the same small problems. The ring starts at three o'clock instead of the top, the stroke gets clipped at the edges, a zero value still shows a little dot, the number next to it jumps around as it changes, and a screen reader hears nothing at all.

None of this needs a chart library. A progress ring is two SVG circles and one bit of math, and the fill animation is a plain CSS transition. This post builds one in React and Tailwind that fills on mount, animates when the value changes, starts at twelve o'clock, reads out as a real progress bar and sits still for people who turned motion off.

I make the Analytics Dashboard V1 on qodesign, which has a goal ring that fills as the numbers count up. That piece uses Framer Motion. The code below is a smaller version with no animation library, written for this post, not the piece's source.

How a progress ring works

You draw a circle with a stroke and no fill. Then you turn the stroke into one long dash with a gap behind it, both as long as the whole circle. Sliding that dash along the circle shows more or less of it. Two SVG attributes do the work:

  • stroke-dasharray sets the dash and gap lengths. Set it to the circumference and you get one dash that covers the full circle.
  • stroke-dashoffset shifts where the dash pattern starts. An offset equal to the circumference hides the dash completely. An offset of zero shows all of it.

So for a value from 0 to 100, the offset is circumference * (1 - value / 100). The circumference is 2 * Math.PI * r, where r is the radius you gave the circle.

Two details trip people up. The radius has to leave room for the stroke, so it's (size - stroke) / 2, not size / 2, or half the stroke gets cut off at the edge of the SVG. And a circle's stroke starts at the right side, at three o'clock, so you rotate the whole SVG by minus 90 degrees to make it start at the top.

Analytics Dashboard V1 running live. A dark analytics dashboard where every number counts up live.

The component

tsx
"use client";

import { useEffect, useState } from "react";

type ProgressRingProps = {
  value: number; // 0 to 100
  label: string; // what the ring measures, read by screen readers
  size?: number; // px
  stroke?: number; // px
};

export function ProgressRing({ value, label, size = 120, stroke = 10 }: ProgressRingProps) {
  const target = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;

  // Start empty, then fill on the next frame so the transition runs on mount.
  const [shown, setShown] = useState(0);
  useEffect(() => {
    const id = requestAnimationFrame(() => setShown(target));
    return () => cancelAnimationFrame(id);
  }, [target]);

  const r = (size - stroke) / 2;
  const circumference = 2 * Math.PI * r;
  const offset = circumference * (1 - shown / 100);

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(target)}
      className="relative inline-grid place-items-center"
      style={{ width: size, height: size }}
    >
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        className="-rotate-90"
        aria-hidden="true"
      >
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          strokeWidth={stroke}
          className="stroke-current text-white/10"
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
          className={`stroke-current text-lime-400 transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none ${
            shown === 0 ? "opacity-0" : "opacity-100"
          }`}
        />
      </svg>
      <span className="absolute text-xl font-semibold tabular-nums text-white">
        {Math.round(target)}%
      </span>
    </div>
  );
}

Why the ring starts empty

If the first render already had the real value, the ring would just appear full and there would be nothing to animate. So shown starts at 0, and an effect sets it to the target on the next animation frame. The browser paints the empty ring first, then the stroke-dashoffset change runs through the transition-[stroke-dashoffset] duration-700 ease-out classes. When the value prop changes later, the same transition carries the ring from the old value to the new one.

Starting at 0 also keeps server rendering simple. The server and the first client render both output an empty ring, so there's no hydration mismatch to chase.

The dot at zero

With strokeLinecap="round" the ends of the arc are rounded, which looks right for any real value. At exactly zero, though, the dash has no length, and a round cap on a zero length stroke still draws a full circle the width of the stroke. MDN's stroke-linecap page describes exactly this. That's the dot you see on a ring that should be empty. The component hides the progress circle with opacity-0 while the shown value is 0.

Screen readers

The SVG is decoration, so it gets aria-hidden. The wrapper carries role="progressbar" with aria-valuenow, plus aria-valuemin and aria-valuemax. Per the progressbar role docs, those last two default to 0 and 100 anyway, but writing them out costs nothing. A progress bar also needs a name, and aria-label is the way to give one when there's no visible label tied to it, so label is a required prop.

Note that aria-valuenow uses the target, not shown. The animation is for eyes. Assistive tech should get the real number right away.

The number in the middle

The percentage uses tabular-nums, so every digit takes the same width and the text doesn't wobble as the value changes. It shows the target value straight away. If you want it to count up with the ring, the animated number counter post has a small hook for that.

Reduced motion

motion-reduce:transition-none turns the transition off when the user has asked for less motion through prefers-reduced-motion. The ring still shows the right value. It just jumps there instead of sweeping. The reduced motion post covers the same setting for other kinds of animation.

Using it

tsx
"use client";

import { useState } from "react";
import { ProgressRing } from "./progress-ring";

export function GoalCard() {
  const [done, setDone] = useState(68);

  return (
    <section className="flex items-center gap-6 rounded-2xl bg-zinc-900 p-6">
      <ProgressRing value={done} label="Monthly goal" />
      <div className="space-y-3">
        <p className="text-sm text-zinc-400">Monthly goal</p>
        <div className="flex gap-2">
          <button
            type="button"
            onClick={() => setDone((v) => Math.max(0, v - 10))}
            className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-400"
          >
            -10
          </button>
          <button
            type="button"
            onClick={() => setDone((v) => Math.min(100, v + 10))}
            className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-lime-400"
          >
            +10
          </button>
        </div>
      </div>
    </section>
  );
}

GoalCard is a client component because it holds state for the buttons. If your value comes from the server, you can render ProgressRing straight from a server component and pass the number in as a prop. The ring itself is the only part that needs "use client".

To change the color by value, swap text-lime-400 for a class you pick from the target, for example red under 30 and lime above 80. The stroke uses stroke-current, so it follows the text color.

Mistakes AI tools make with progress rings

  • Using size / 2 as the radius, so the stroke is clipped at the edges of the SVG.
  • Forgetting the minus 90 degree rotation, so the ring fills from the right instead of the top.
  • Leaving out viewBox, so the ring scales oddly when you change its size with CSS.
  • Animating the value with setInterval and React state, which re-renders many times a second for something a CSS transition does on its own.
  • Using transition-all, which also animates color and opacity changes you didn't mean to animate.
  • Keeping a round line cap at zero, which leaves a dot on an empty ring.
  • No role="progressbar" and no name, so the ring is invisible to screen readers.
  • Not clamping the value, so 120 or NaN from an API breaks the math.

A prompt for Cursor, v0 or Lovable

text
Build a ProgressRing React component with Tailwind and no libraries.
- Props: value (0 to 100), label (required), size (default 120), stroke (default 10).
- Clamp value to 0..100 and treat NaN as 0.
- SVG with viewBox 0 0 size size, rotated with -rotate-90 so it starts at the top. aria-hidden on the SVG.
- Track circle: stroke-current text-white/10. Progress circle: round line cap, stroke-current text-lime-400.
- r = (size - stroke) / 2, circumference = 2 * PI * r, strokeDasharray = circumference, strokeDashoffset = circumference * (1 - shown / 100).
- shown starts at 0 and is set to the clamped value inside requestAnimationFrame in an effect, so it fills on mount.
- Classes on the progress circle: transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none, and opacity-0 when shown is 0.
- Wrapper: role="progressbar", aria-label from label, aria-valuemin 0, aria-valuemax 100, aria-valuenow = the rounded clamped value.
- Center label: the percentage with tabular-nums.

Rings, charts and checkmarks

The same dash trick shows up in other places on this blog. The animated SVG line chart post uses it to draw a line from left to right, and the animated checkmark post uses it to close a ring and draw a check after a form succeeds.

If you'd rather start from a finished dashboard, the Analytics Dashboard V1 has the goal ring together with counting KPI numbers, a spring bar chart with tooltips and a Week/Month switch that animates everything to the new data.

FAQ

How do I make a circular progress bar in React without a library?

Draw two SVG circles with the same radius, a faint track and a colored progress circle. Set strokeDasharray on the progress circle to its circumference and strokeDashoffset to circumference * (1 - value / 100). Rotate the SVG by minus 90 degrees so it starts at the top, and add a CSS transition on stroke-dashoffset to animate it.

Why does my SVG progress ring start at three o'clock?

An SVG circle's stroke starts on its right side. Rotate the SVG by minus 90 degrees, for example with Tailwind's -rotate-90, and the fill starts at twelve o'clock.

Why does my empty progress ring show a dot?

With a round line cap, a stroke of zero length is still drawn as a small circle. Hide the progress circle when the value is 0, or use a butt line cap.

How do I make a progress ring accessible?

Hide the SVG with aria-hidden and put role="progressbar" on a wrapper with aria-valuenow, aria-valuemin, aria-valuemax and a name from aria-label or aria-labelledby. Turn the fill transition off when the user prefers reduced motion.