Circular Progress

A ring fills to the new value while the number in the middle counts up with it.

  • JS
  • SVG & shapes
  • Click
  • Added
0%
Profile complete

AI prompt

Build a React circular progress ring in SVG. Draw two circles with r = 52 in a 120×120 viewBox: a track in a muted colour and a progress circle with stroke-dasharray equal to the circumference (2πr) and stroke-dashoffset = circumference × (1 − value). Rotate the progress circle −90° so it starts at 12 o’clock and use stroke-linecap: round. Animate from the old to the new value with requestAnimationFrame over 900ms using an ease-out cubic curve, updating both the dashoffset and the percentage text in the centre on each frame (tabular-nums). Expose it as role="progressbar" with aria-valuenow, aria-valuemin 0, aria-valuemax 100 and aria-label "Profile complete". Add a button that sets a new random value. Under prefers-reduced-motion, jump straight to the new value.

Source

Demo.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'

const R = 52
const C = 2 * Math.PI * R

export default function CircularProgress({ labels }: { labels: Record<string, string> }) {
  const [shown, setShown] = useState(0)
  const [goal, setGoal] = useState(0)
  const target = useRef(0)
  const frame = useRef(0)

  const animateTo = (next: number) => {
    cancelAnimationFrame(frame.current)
    const from = target.current
    target.current = next
    setGoal(next)
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return setShown(next)
    const t0 = performance.now()
    const tick = (now: number) => {
      const t = Math.min((now - t0) / 900, 1)
      setShown(from + (next - from) * (1 - (1 - t) ** 3))
      if (t < 1) frame.current = requestAnimationFrame(tick)
    }
    frame.current = requestAnimationFrame(tick)
  }

  useEffect(() => {
    // start on the next frame so the ring animates in from 0 after mount
    frame.current = requestAnimationFrame(() => animateTo(72))
    return () => cancelAnimationFrame(frame.current)
  }, [])

  const pct = Math.round(shown)

  return (
    <div className={s.wrap}>
      <div className={s.ring} role="progressbar" aria-valuenow={goal} aria-valuemin={0} aria-valuemax={100} aria-label={labels.label}>
        <svg viewBox="0 0 120 120" aria-hidden="true">
          <circle className={s.track} cx="60" cy="60" r={R} />
          <circle className={s.bar} cx="60" cy="60" r={R} strokeDasharray={C} strokeDashoffset={C * (1 - shown / 100)} />
        </svg>
        <span className={s.value}>{pct}%</span>
      </div>
      <span className={s.label}>{labels.label}</span>
      <button type="button" className={s.button} onClick={() => animateTo(10 + Math.floor(Math.random() * 90))}>
        {labels.again}
      </button>
    </div>
  )
}
demo.module.css
.wrap { display: grid; justify-items: center; gap: 8px; }

.ring { position: relative; width: 120px; height: 120px; }
.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }

.track, .bar { fill: none; stroke-width: 10; }
.track { stroke: var(--stage-rule); }
.bar { stroke: var(--stage-accent); stroke-linecap: round; }

.value {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font: 600 var(--text-2xl) / 1 var(--font-display);
  font-variant-numeric: tabular-nums;
  color: var(--stage-ink);
}

.label { font-size: var(--text-xs); color: var(--stage-muted); }

.button {
  height: 32px;
  padding: 0 12px;
  border: 1px solid var(--stage-rule);
  border-radius: 8px;
  background: var(--stage-surface);
  color: var(--stage-ink);
  font: 500 var(--text-sm) / 1 var(--font-body);
  white-space: nowrap;
  cursor: pointer;
}

.button:hover { border-color: var(--stage-accent); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.