Gradient Stat Ring — Free HTML CSS JS Progress Ring Snippet

Gradient Stat Ring · Charts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Pure SVG rings
Stroke-dash geometry, no chart library.
Gradient strokes
Per-ring linearGradient stroke fills.
Counting label
Centre number eases up to the value.
Synced timing
Arc and count finish together.
Top-start origin
SVG rotated so fill begins at 12 o'clock.
Tabular figures
Equal-width digits prevent jitter.
Scroll-triggered
IntersectionObserver fires once in view.
Clamped values
0–100 guard prevents overdraw.

About this UI Snippet

Gradient Stat Ring — Circular Progress With a Counting Label

Screenshot of the Gradient Stat Ring snippet rendered live

The gradient stat ring is the circular progress indicator you see on dashboards and fitness apps — a gradient arc that sweeps around to represent a percentage while the number in the centre counts up to match. This snippet builds a responsive grid of them with pure SVG, CSS, and a small vanilla JavaScript driver, with no charting library.

Stroke-dash geometry

Each ring is two stacked SVG circles: a muted track and a coloured fill, both with the same radius. The fill's progress is controlled by the classic stroke-dasharray / stroke-dashoffset trick. The dash array is set to the full circumference (2πr) so a single dash wraps the whole circle, and the offset is then set to the same length to hide it entirely. Animating the offset down to circumference × (1 − value/100) reveals exactly that fraction of the arc, which is how the ring "fills".

Why the SVG is rotated

By default SVG circles start their stroke at 3 o'clock, so the whole <svg> is rotated -90deg to move the start point to 12 o'clock — the natural top-of-the-clock origin people expect from a progress ring. The centre percentage <text> is then counter-rotated 90deg around the ring's centre so the label stays upright despite the parent rotation.

Gradient strokes

Each fill is painted with stroke="url(#id)" referencing an SVG <linearGradient>, giving the arc a two-colour gradient that reads as premium and distinguishes one metric from another. Because the gradient is defined per-SVG with a unique id, several rings can carry different colour ramps on the same page without clashing.

Synchronised sweep and count

The arc animation is pure CSS — a transition on stroke-dashoffset with an ease-out cubic-bezier so it decelerates as it lands. The centre number is animated separately in JavaScript with requestAnimationFrame over the same duration and an ease-out curve, so the label and the arc finish together. The figure uses font-variant-numeric: tabular-nums so it does not jitter horizontally while counting.

Scroll-triggered, once

An IntersectionObserver starts every ring when the grid is 30% in view and then disconnects, so the fill and count play exactly once as the section appears — the standard performant trigger, with no scroll listener. Values are clamped to 0–100 so out-of-range data can't overdraw the arc.

Customizing it

Set each ring's target with a data-value attribute, change the gradient stops, adjust the stroke-width for thicker or thinner arcs, or tune the transition duration. Add more cards and the grid wraps responsively. Pair the rings with a metric card grid or a glass stat card for a complete dashboard panel.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Rather than working out the trigonometry of the stroke-dash trick by hand, hand this snippet's HTML, CSS, and JS to an AI coding assistant like Claude and ask it to explain precisely why the svg element is rotated -90 degrees while the percentage text inside it is separately counter-rotated 90 degrees around the ring's own center, and how the stroke-dashoffset formula circumference times one minus value over 100 actually reveals the arc. The same assistant is useful for optimization too — ask whether running a requestAnimationFrame loop per ring scales fine for a dozen rings on one dashboard or whether the count-up should share a single rAF driver across all rings. It's also a quick way to extend the effect: ask it to add a second, thinner ring showing a target or comparison value, support negative or over-100 values with a visual warning color, or replay the animation whenever the underlying data value changes rather than only once on scroll. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a grid of circular gradient progress rings in plain SVG, CSS, and vanilla JavaScript, no charting library, triggered once via IntersectionObserver.

Requirements:
- Each ring is an svg containing two concentric circles of the same radius: a muted track circle and a colored fill circle, plus a linearGradient definition with a unique id per ring referenced by the fill circle's stroke attribute as a url reference.
- The fill circle's progress must be controlled with stroke-dasharray set to the full circumference (2 times pi times the radius) so one dash wraps the entire circle, and stroke-dashoffset animated from the full circumference (fully hidden) down to circumference times one minus the target value over 100 (revealing that fraction of the arc).
- The whole svg element must be rotated -90 degrees so the arc begins at the 12 o'clock position instead of the default 3 o'clock start, and the centered percentage text element must be separately rotated 90 degrees around the ring's own center point so it reads upright despite the parent rotation.
- The stroke-dashoffset change should animate via a CSS transition with an ease-out cubic-bezier curve, and independently, a centered percentage label must count up from 0 to the target value using requestAnimationFrame with a matching ease-out easing function over the same duration, so the number and the arc finish together.
- Use font-variant-numeric: tabular-nums on the percentage label so the digits do not shift horizontally while counting.
- Clamp every target value to between 0 and 100 before using it in any calculation.
- Use a single IntersectionObserver watching the ring grid's container, firing the sweep-and-count animation for every ring only once when at least 30 percent of the grid is visible, then disconnecting the observer.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSThree stat rings render empty at 0%.
  2. 2
    Scroll them into viewEach arc sweeps to its value and the number counts up.
  3. 3
    Note the gradientsEvery ring carries its own two-colour stroke.
  4. 4
    Set a valueChange a ring's data-value attribute.
  5. 5
    Recolor a ringEdit its linearGradient stops.
  6. 6
    Adjust thicknessTune the stroke-width on track and fill.

Real-world uses

Common Use Cases

Dashboards
Show KPIs beside a metric card grid.
Usage meters
Storage and quota next to a glass stat card.
Fitness apps
Pair activity rings with a streak tracker.
Goal tracking
Progress toward a target in a feature checklist.
Reports
Headline percentages above a donut chart.
Status panels
Resource load on a status dashboard.

Got questions?

Frequently Asked Questions

Each ring is two SVG circles — a track and a fill — with the fill controlled by stroke-dasharray and stroke-dashoffset. The dash array equals the circumference, 2 times pi times r, so one dash wraps the circle; the offset starts at the full length to hide it, then animates down to circumference times (1 minus value over 100) to reveal exactly that fraction.

By default an SVG circle's stroke begins at 3 o'clock, so the whole svg is rotated -90 degrees to move the start to 12 o'clock, the origin people expect from a progress ring. The centre percentage text is counter-rotated 90 degrees around the ring's centre so the label stays upright.

The arc uses a CSS transition on stroke-dashoffset with an ease-out cubic-bezier. The centre number is animated separately with requestAnimationFrame over the same duration and an ease-out curve, so both finish together. The figure uses tabular-nums so it does not shift horizontally while counting.

Yes. Each fill references its own SVG linearGradient by id with stroke="url(#id)", so every ring can carry a distinct two-colour ramp on the same page without clashing. Change the gradient stops to recolour a ring, and keep the ids unique per ring.

Render the SVG in your template and compute strokeDashoffset from a value prop as circumference times (1 minus value/100). Run the centre count-up in a mount effect with requestAnimationFrame, writing to a ref'd text node, and start it from an IntersectionObserver. Give each gradient a unique id (e.g. include the component key) so multiple instances do not collide.