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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.