Display
ProgressRing
Circular progress indicator with an animated counter
Preview
90%
size
value
max
suffix
Code
import css from "./ProgressRing.module.css";
import { useEffect, useRef, useState } from "react";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ProgressRingProps {
size?: Size | ResponsiveSize;
customSize?: string;
value: number;
max: number;
// Named after the attribute it becomes: the ring draws only the number.
"aria-label": string;
prefix?: string;
suffix?: string;
}
const DURATION = 1200;
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function ProgressRing({
size,
customSize,
value,
max,
"aria-label": ariaLabel,
prefix,
suffix,
}: ProgressRingProps) {
const ring = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
const shown = useRef({ value: 0, fill: 0 });
const [inView, setInView] = useState(false);
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!ring.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ threshold: 0.4 },
);
observer.observe(ring.current);
return () => observer.disconnect();
}, []);
// Firefox does not repaint a conic-gradient while an animated @property changes, so JS drives
// --ring-fill per frame.
// TODO(firefox): revert to pure CSS once that lands — see git history.
useEffect(() => {
if (!inView) return;
const fill = max > 0 ? (value / max) * 100 : 0;
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (reduce) {
shown.current = { value, fill };
setDisplay(value);
track.current?.style.setProperty("--ring-fill", `${fill}%`);
return;
}
const from = shown.current;
let raf = 0;
let start: number | null = null;
const tick = (now: number) => {
if (start === null) start = now;
const progress = Math.min((now - start) / DURATION, 1);
const eased = easeOutCubic(progress);
shown.current =
progress < 1
? {
value: from.value + (value - from.value) * eased,
fill: from.fill + (fill - from.fill) * eased,
}
: { value, fill };
setDisplay(progress < 1 ? Math.round(shown.current.value) : value);
track.current?.style.setProperty("--ring-fill", `${shown.current.fill}%`);
if (progress < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, value, max]);
return (
<div
ref={ring}
className={`
${css.ring}
${size ? getSizeClasses(size) : ""}
`}
style={
customSize
? ({ "--ring-diameter": customSize } as React.CSSProperties)
: undefined
}
>
<div
ref={track}
className={css.ring_track}
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-valuetext={`${prefix ?? ""}${value}${suffix ?? ""}`}
aria-label={ariaLabel}
></div>
<p className={`${css.ring_value} ${inView ? "" : css.pending}`}>
{prefix && <span className={css.value_prefix}>{prefix}</span>}
{inView ? display : value}
{suffix && <span className={css.value_suffix}>{suffix}</span>}
</p>
</div>
);
}
@property --ring-fill {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.ring {
display: grid;
justify-items: center;
/* Rides on the diameter, so customSize — a clamp set inline — scales it too. */
--ring-thickness: calc(var(--ring-diameter) * 0.1);
/* Only the diameter steps: thickness (×0.1) and number (×0.3) derive from it. */
&.sm {
--ring-diameter: 80px;
}
&.md {
--ring-diameter: 100px;
}
&.lg {
--ring-diameter: 120px;
}
&.sm_base {
--ring-diameter: 80px;
}
&.md_base {
--ring-diameter: 100px;
}
&.lg_base {
--ring-diameter: 120px;
}
@media (min-width: 480px) {
&.sm_mobile {
--ring-diameter: 80px;
}
&.md_mobile {
--ring-diameter: 100px;
}
&.lg_mobile {
--ring-diameter: 120px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
--ring-diameter: 80px;
}
&.md_tablet {
--ring-diameter: 100px;
}
&.lg_tablet {
--ring-diameter: 120px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--ring-diameter: 80px;
}
&.md_desktop {
--ring-diameter: 100px;
}
&.lg_desktop {
--ring-diameter: 120px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
--ring-diameter: 80px;
}
&.md_wide {
--ring-diameter: 100px;
}
&.lg_wide {
--ring-diameter: 120px;
}
}
/* The circle and the number share the same grid cell → stacked. */
.ring_track {
grid-row: 1;
grid-column: 1;
width: var(--ring-diameter);
aspect-ratio: 1;
border-radius: var(--radius-full);
--ring-fill: 0%;
background: conic-gradient(
var(--color-accent-secondary) 0%,
var(--color-accent) var(--ring-fill),
color-mix(in srgb, var(--color-text-tertiary) 25%, transparent)
var(--ring-fill)
);
-webkit-mask: radial-gradient(
farthest-side,
transparent calc(100% - var(--ring-thickness)),
#000 calc(100% - var(--ring-thickness))
);
mask: radial-gradient(
farthest-side,
transparent calc(100% - var(--ring-thickness)),
#000 calc(100% - var(--ring-thickness))
);
}
.ring_value {
grid-row: 1;
grid-column: 1;
align-self: center;
font-family: var(--font-heading);
font-size: calc(var(--ring-diameter) * 0.3);
font-weight: var(--weight-800);
line-height: 1;
color: var(--color-text);
&.pending {
@media (scripting: enabled) {
opacity: 0;
}
}
/* em, not a token: both ride on the number, which scales with the diameter. */
.value_prefix,
.value_suffix {
font-size: 0.6em;
}
}
}
<script setup lang="ts">
import {
computed,
onMounted,
onUnmounted,
ref,
useTemplateRef,
watchEffect,
} from "vue";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ProgressRingProps {
size?: Size | ResponsiveSize;
customSize?: string;
value: number;
max: number;
// Named after the attribute it becomes: the ring draws only the number.
ariaLabel: string;
prefix?: string;
suffix?: string;
}
const DURATION = 1200;
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);
const { size, value, max } = defineProps<ProgressRingProps>();
const ring = useTemplateRef<HTMLDivElement>("ring");
const track = useTemplateRef<HTMLDivElement>("track");
const inView = ref(false);
const display = ref(0);
let shown = { value: 0, fill: 0 };
let observer: IntersectionObserver | undefined;
const sizeClasses = computed(() => {
if (!size) return "";
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
onMounted(() => {
if (!ring.value) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true;
observer?.disconnect();
}
},
{ threshold: 0.4 },
);
observer.observe(ring.value);
});
onUnmounted(() => observer?.disconnect());
// Firefox does not repaint a conic-gradient while an animated @property changes, so JS drives
// --ring-fill per frame.
// TODO(firefox): revert to pure CSS once that lands — see git history.
watchEffect(
(onCleanup) => {
if (!inView.value) return;
const target = value;
const fill = max > 0 ? (value / max) * 100 : 0;
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (reduce) {
shown = { value: target, fill };
display.value = target;
track.value?.style.setProperty("--ring-fill", `${fill}%`);
return;
}
const from = shown;
let raf = 0;
let start: number | null = null;
const tick = (now: number) => {
if (start === null) start = now;
const progress = Math.min((now - start) / DURATION, 1);
const eased = easeOutCubic(progress);
shown =
progress < 1
? {
value: from.value + (target - from.value) * eased,
fill: from.fill + (fill - from.fill) * eased,
}
: { value: target, fill };
display.value = progress < 1 ? Math.round(shown.value) : target;
track.value?.style.setProperty("--ring-fill", `${shown.fill}%`);
if (progress < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
onCleanup(() => cancelAnimationFrame(raf));
},
{ flush: "post" },
);
</script>
<template>
<div
ref="ring"
class="ring"
:class="sizeClasses"
:style="customSize ? { '--ring-diameter': customSize } : undefined"
>
<div
ref="track"
class="ring_track"
role="progressbar"
:aria-valuenow="value"
:aria-valuemin="0"
:aria-valuemax="max"
:aria-valuetext="`${prefix ?? ''}${value}${suffix ?? ''}`"
:aria-label="ariaLabel"
></div>
<p class="ring_value" :class="{ pending: !inView }"><span v-if="prefix" class="value_prefix">{{ prefix }}</span>{{ inView ? display : value }}<span v-if="suffix" class="value_suffix">{{ suffix }}</span></p>
</div>
</template>
<style scoped>
@property --ring-fill {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.ring {
display: grid;
justify-items: center;
/* Rides on the diameter, so customSize — a clamp set inline — scales it too. */
--ring-thickness: calc(var(--ring-diameter) * 0.1);
/* Only the diameter steps: thickness (×0.1) and number (×0.3) derive from it. */
&.sm {
--ring-diameter: 80px;
}
&.md {
--ring-diameter: 100px;
}
&.lg {
--ring-diameter: 120px;
}
&.sm_base {
--ring-diameter: 80px;
}
&.md_base {
--ring-diameter: 100px;
}
&.lg_base {
--ring-diameter: 120px;
}
@media (min-width: 480px) {
&.sm_mobile {
--ring-diameter: 80px;
}
&.md_mobile {
--ring-diameter: 100px;
}
&.lg_mobile {
--ring-diameter: 120px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
--ring-diameter: 80px;
}
&.md_tablet {
--ring-diameter: 100px;
}
&.lg_tablet {
--ring-diameter: 120px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--ring-diameter: 80px;
}
&.md_desktop {
--ring-diameter: 100px;
}
&.lg_desktop {
--ring-diameter: 120px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
--ring-diameter: 80px;
}
&.md_wide {
--ring-diameter: 100px;
}
&.lg_wide {
--ring-diameter: 120px;
}
}
/* The circle and the number share the same grid cell → stacked. */
.ring_track {
grid-row: 1;
grid-column: 1;
width: var(--ring-diameter);
aspect-ratio: 1;
border-radius: var(--radius-full);
--ring-fill: 0%;
background: conic-gradient(
var(--color-accent-secondary) 0%,
var(--color-accent) var(--ring-fill),
color-mix(in srgb, var(--color-text-tertiary) 25%, transparent)
var(--ring-fill)
);
-webkit-mask: radial-gradient(
farthest-side,
transparent calc(100% - var(--ring-thickness)),
#000 calc(100% - var(--ring-thickness))
);
mask: radial-gradient(
farthest-side,
transparent calc(100% - var(--ring-thickness)),
#000 calc(100% - var(--ring-thickness))
);
}
.ring_value {
grid-row: 1;
grid-column: 1;
align-self: center;
font-family: var(--font-heading);
font-size: calc(var(--ring-diameter) * 0.3);
font-weight: var(--weight-800);
line-height: 1;
color: var(--color-text);
&.pending {
@media (scripting: enabled) {
opacity: 0;
}
}
/* em, not a token: both ride on the number, which scales with the diameter. */
.value_prefix,
.value_suffix {
font-size: 0.6em;
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | ResponsiveSize | — | Ring diameter. Omit it only if you pass customSize. Fixed or responsive. |
| customSize | string | — | Custom diameter (any CSS unit); replaces size. |
| value | number | required | Center number and progress; counts from 0 on entering the viewport (honors reduced-motion). |
| max | number | required | Value that fills the whole ring; the arc is value / max. |
| aria-label | string | required | Name of the ring for screen readers; the ring only draws the number. |
| prefix | string | — | Text before the number (e.g. "+"). |
| suffix | string | — | Text after the number (e.g. "%"). |