01Triple activity rings
Three concentric rings actually fill at their own pace, and clicking "refill" draws three new values and winds each ring to its new stop.
Best forA health or habit dashboard that stacks three metrics like steps, exercise, and sleep in one card
stroke-dashoffset3 circlesclick

<main class="stage">
<div class="ar is-demo">
<svg class="ar__svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="Three activity rings">
<g class="ar__track">
<circle cx="60" cy="60" r="52"/>
<circle cx="60" cy="60" r="40"/>
<circle cx="60" cy="60" r="28"/>
</g>
<g class="ar__vals">
<circle class="ar__ring ar__ring--move" cx="60" cy="60" r="52"/>
<circle class="ar__ring ar__ring--burn" cx="60" cy="60" r="40"/>
<circle class="ar__ring ar__ring--rest" cx="60" cy="60" r="28"/>
</g>
</svg>
<div class="ar__side">
<ul class="ar__legend">
<li class="ar__item ar__item--move">
<span class="ar__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M152 192h56v12a28 28 0 0 1-56 0ZM48 180a28 28 0 0 0 56 0v-12H48ZM76 24c-24.52 0-51.46 80-25.26 112h50.52c26.2-32-.74-112-25.26-112m129.26 136c26.2-32-.74-112-25.26-112s-51.46 80-25.26 112Z" opacity=".2"/><path d="M208.06 184H152a8 8 0 0 0-8 8v12a36 36 0 0 0 72.05 0v-12a8 8 0 0 0-7.99-8m-8 20a20 20 0 0 1-40 0v-4h40ZM104 160H48a8 8 0 0 0-8 8v12a36 36 0 0 0 72 0v-12a8 8 0 0 0-8-8m-8 20a20 20 0 0 1-40 0v-4h40ZM76 16c-11.64 0-22.93 10.31-31.8 29c-13.93 29.38-18.56 73 .29 96a8 8 0 0 0 6.2 2.93h50.55a8 8 0 0 0 6.2-2.93c18.85-23 14.22-66.65.29-96C98.85 26.31 87.57 16 76 16m21.15 112H54.78c-11.4-18.1-7.21-52.7 3.89-76.11C65.14 38.22 72.17 32 76 32s10.82 6.22 17.3 19.89c11.06 23.41 15.25 58.01 3.85 76.11m57.61 40h50.55a8 8 0 0 0 6.2-2.93c18.85-23 14.22-66.65.29-96C202.93 50.31 191.64 40 180 40s-22.89 10.31-31.77 29c-13.93 29.38-18.56 73 .29 96a8 8 0 0 0 6.24 3m8-92.11C169.22 62.22 176.25 56 180 56s10.81 6.22 17.29 19.89c11.1 23.41 15.29 58 3.89 76.11h-42.33c-11.4-18.1-7.21-52.7 3.89-76.11Z"/></g></svg></span>
<span class="ar__name">Steps</span>
<b class="ar__pct ar__pct--move"></b>
</li>
<li class="ar__item ar__item--burn">
<span class="ar__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-77.94 80A40 40 0 0 0 168 184c0-40-40-64-40-64s-40 24-40 64a40 40 0 0 0 37.94 40A80 80 0 0 1 48 144c0-72 80-120 80-120s80 48 80 120" opacity=".2"/><path d="M173.79 51.48a221.3 221.3 0 0 0-41.67-34.34a8 8 0 0 0-8.24 0a221.3 221.3 0 0 0-41.67 34.34C54.59 80.48 40 112.47 40 144a88 88 0 0 0 176 0c0-31.53-14.59-63.52-42.21-92.52M96 184c0-27.67 22.53-47.28 32-54.3c9.48 7 32 26.63 32 54.3a32 32 0 0 1-64 0m77.27 15.93A47.8 47.8 0 0 0 176 184c0-44-42.09-69.79-43.88-70.86a8 8 0 0 0-8.24 0C122.09 114.21 80 140 80 184a47.8 47.8 0 0 0 2.73 15.93A71.88 71.88 0 0 1 56 144c0-34.41 20.4-63.15 37.52-81.19A216.2 216.2 0 0 1 128 33.54a215.8 215.8 0 0 1 34.48 29.27C193.49 95.5 200 125 200 144a71.88 71.88 0 0 1-26.73 55.93"/></g></svg></span>
<span class="ar__name">Move</span>
<b class="ar__pct ar__pct--burn"></b>
</li>
<li class="ar__item ar__item--rest">
<span class="ar__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M210.69 158.18A88 88 0 1 1 97.82 45.31A96.08 96.08 0 0 0 192 160a97 97 0 0 0 18.69-1.82" opacity=".2"/><path d="M240 96a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16V72a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-96-40h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16m72.77 97a8 8 0 0 1 1.43 8A96 96 0 1 1 95.07 37.8a8 8 0 0 1 10.6 9.06a88.07 88.07 0 0 0 103.47 103.47a8 8 0 0 1 7.63 2.67m-19.39 14.88c-1.79.09-3.59.14-5.38.14A104.11 104.11 0 0 1 88 64c0-1.79 0-3.59.14-5.38a80 80 0 1 0 109.24 109.24Z"/></g></svg></span>
<span class="ar__name">Sleep</span>
<b class="ar__pct ar__pct--rest"></b>
</li>
</ul>
<button class="ar__btn" type="button">Refill</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
// 등록하지 않은 커스텀 속성은 keyframes 안에서 보간되지 않는다 — 45% 에서 툭 끊긴다
@property --p1 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --p2 { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --p3 { syntax: "<integer>"; inherits: true; initial-value: 0; }
.ar {
--p1: 82;
--p2: 64;
--p3: 45;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-4;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
transition: --p1 $dur-enter $easing, --p2 $dur-enter $easing, --p3 $dur-enter $easing;
}
.ar__svg {
width: clamp(104px, 30vw, 120px);
height: clamp(104px, 30vw, 120px);
flex: 0 0 auto;
transform: rotate(-90deg);
}
.ar__track circle {
fill: none;
stroke: rgba(255, 247, 230, .14);
stroke-width: 10;
}
.ar__ring {
fill: none;
stroke-width: 10;
stroke-linecap: round;
}
// 둘레 C = 2πr 를 통째로 점선 한 칸으로 쓰고, 안 채운 만큼을 offset 으로 민다
.ar__ring--move { stroke: $color; stroke-dasharray: 326.726; stroke-dashoffset: calc(326.726 * (1 - var(--p1) / 100)); }
.ar__ring--burn { stroke: $subject-mint; stroke-dasharray: 251.327; stroke-dashoffset: calc(251.327 * (1 - var(--p2) / 100)); }
.ar__ring--rest { stroke: $subject-sky; stroke-dasharray: 175.929; stroke-dashoffset: calc(175.929 * (1 - var(--p3) / 100)); }
.ar__side { display: grid; gap: $sp-3; }
.ar__legend { list-style: none; margin: 0; padding: 0; display: grid; gap: $sp-2; }
.ar__item {
display: flex;
align-items: center;
gap: $sp-2;
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, .72);
}
.ar__ic { display: inline-flex; }
.ar__item--move .ar__ic { color: $color; }
.ar__item--burn .ar__ic { color: $subject-mint; }
.ar__item--rest .ar__ic { color: $subject-sky; }
.ar__name { min-width: 32px; }
.ar__pct { font-variant-numeric: tabular-nums; color: $subject-cream; font-size: 13px; }
.ar__pct--move::after { counter-reset: p var(--p1); content: counter(p) "%"; }
.ar__pct--burn::after { counter-reset: p var(--p2); content: counter(p) "%"; }
.ar__pct--rest::after { counter-reset: p var(--p3); content: counter(p) "%"; }
.ar__btn {
border: 0;
border-radius: $r-pill;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
padding: $sp-2 $sp-3;
cursor: pointer;
transition: background $dur-quick $ease-out;
}
.ar__btn:hover, .ar__btn:focus-visible { background: rgba(255, 247, 230, .24); }
// ── 자동 루프: 감았다 푼다. 끝에서 처음으로 튀지 않게 다시 0 으로 돌아온다 ──
.ar.is-demo { animation: ar-wind $duration $easing infinite; }
@keyframes ar-wind {
0% { --p1: 0; --p2: 0; --p3: 0; }
45%, 55% { --p1: 82; --p2: 64; --p3: 45; }
100% { --p1: 0; --p2: 0; --p3: 0; }
}
@media (max-width: 340px) {
.ar { gap: $sp-3; }
.ar__item { font-size: 11px; }
.ar__name { min-width: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.ar.is-demo { animation: none !important; }
.ar, .ar__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --p1 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --p2 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --p3 {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.ar {
--p1: 82;
--p2: 64;
--p3: 45;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 16px;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
transition: --p1 600ms cubic-bezier(0.2, 0.8, 0.2, 1), --p2 600ms cubic-bezier(0.2, 0.8, 0.2, 1), --p3 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ar__svg {
width: clamp(104px, 30vw, 120px);
height: clamp(104px, 30vw, 120px);
flex: 0 0 auto;
transform: rotate(-90deg);
}
.ar__track circle {
fill: none;
stroke: rgba(255, 247, 230, 0.14);
stroke-width: 10;
}
.ar__ring {
fill: none;
stroke-width: 10;
stroke-linecap: round;
}
.ar__ring--move {
stroke: #ff9ebb;
stroke-dasharray: 326.726;
stroke-dashoffset: calc(326.726 * (1 - var(--p1) / 100));
}
.ar__ring--burn {
stroke: #4cd4a6;
stroke-dasharray: 251.327;
stroke-dashoffset: calc(251.327 * (1 - var(--p2) / 100));
}
.ar__ring--rest {
stroke: #6ec8ff;
stroke-dasharray: 175.929;
stroke-dashoffset: calc(175.929 * (1 - var(--p3) / 100));
}
.ar__side {
display: grid;
gap: 12px;
}
.ar__legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.ar__item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.72);
}
.ar__ic {
display: inline-flex;
}
.ar__item--move .ar__ic {
color: #ff9ebb;
}
.ar__item--burn .ar__ic {
color: #4cd4a6;
}
.ar__item--rest .ar__ic {
color: #6ec8ff;
}
.ar__name {
min-width: 32px;
}
.ar__pct {
font-variant-numeric: tabular-nums;
color: #fff7e6;
font-size: 13px;
}
.ar__pct--move::after {
counter-reset: p var(--p1);
content: counter(p) "%";
}
.ar__pct--burn::after {
counter-reset: p var(--p2);
content: counter(p) "%";
}
.ar__pct--rest::after {
counter-reset: p var(--p3);
content: counter(p) "%";
}
.ar__btn {
border: 0;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
padding: 8px 12px;
cursor: pointer;
transition: background 220ms ease-out;
}
.ar__btn:hover, .ar__btn:focus-visible {
background: rgba(255, 247, 230, 0.24);
}
.ar.is-demo {
animation: ar-wind 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ar-wind {
0% {
--p1: 0;
--p2: 0;
--p3: 0;
}
45%, 55% {
--p1: 82;
--p2: 64;
--p3: 45;
}
100% {
--p1: 0;
--p2: 0;
--p3: 0;
}
}
@media (max-width: 340px) {
.ar {
gap: 12px;
}
.ar__item {
font-size: 11px;
}
.ar__name {
min-width: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.ar.is-demo {
animation: none !important;
}
.ar, .ar__btn {
transition: none;
}
}// 실제 동작: 세 값을 새로 뽑아 --p1 --p2 --p3 을 바꾼다. 링과 숫자가 같은 변수를 읽으므로 둘이 어긋나지 않는다.
var ar = document.querySelector('.ar');
ar.querySelector('.ar__btn').addEventListener('click', function () {
ar.classList.remove('is-demo');
for (var i = 1; i <= 3; i++) {
ar.style.setProperty('--p' + i, String(25 + Math.floor(Math.random() * 74)));
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TripleActivityRings.module.scss";
export type TripleActivityRingsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--p1"?: number;
"--p2"?: number;
"--p3"?: number;
};
function roll(): number {
return 25 + Math.floor(Math.random() * 74);
}
export default function TripleActivityRings({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff9ebb",
}: TripleActivityRingsProps) {
const [p, setP] = useState<[number, number, number]>([82, 64, 45]);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--p1": p[0],
"--p2": p[1],
"--p3": p[2],
};
return (
<div className={styles.ar} style={vars}>
<svg
className={styles.svg}
viewBox="0 0 120 120"
width="120"
height="120"
role="img"
aria-label="Three activity rings"
>
<g className={styles.track}>
<circle cx="60" cy="60" r="52" />
<circle cx="60" cy="60" r="40" />
<circle cx="60" cy="60" r="28" />
</g>
<g>
<circle className={`${styles.ring} ${styles.ringMove}`} cx="60" cy="60" r="52" />
<circle className={`${styles.ring} ${styles.ringBurn}`} cx="60" cy="60" r="40" />
<circle className={`${styles.ring} ${styles.ringRest}`} cx="60" cy="60" r="28" />
</g>
</svg>
<div className={styles.side}>
<ul className={styles.legend}>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.icMove}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path
d="M152 192h56v12a28 28 0 0 1-56 0ZM48 180a28 28 0 0 0 56 0v-12H48ZM76 24c-24.52 0-51.46 80-25.26 112h50.52c26.2-32-.74-112-25.26-112m129.26 136c26.2-32-.74-112-25.26-112s-51.46 80-25.26 112Z"
opacity=".2"
/>
<path d="M208.06 184H152a8 8 0 0 0-8 8v12a36 36 0 0 0 72.05 0v-12a8 8 0 0 0-7.99-8m-8 20a20 20 0 0 1-40 0v-4h40ZM104 160H48a8 8 0 0 0-8 8v12a36 36 0 0 0 72 0v-12a8 8 0 0 0-8-8m-8 20a20 20 0 0 1-40 0v-4h40ZM76 16c-11.64 0-22.93 10.31-31.8 29c-13.93 29.38-18.56 73 .29 96a8 8 0 0 0 6.2 2.93h50.55a8 8 0 0 0 6.2-2.93c18.85-23 14.22-66.65.29-96C98.85 26.31 87.57 16 76 16m21.15 112H54.78c-11.4-18.1-7.21-52.7 3.89-76.11C65.14 38.22 72.17 32 76 32s10.82 6.22 17.3 19.89c11.06 23.41 15.25 58.01 3.85 76.11m57.61 40h50.55a8 8 0 0 0 6.2-2.93c18.85-23 14.22-66.65.29-96C202.93 50.31 191.64 40 180 40s-22.89 10.31-31.77 29c-13.93 29.38-18.56 73 .29 96a8 8 0 0 0 6.24 3m8-92.11C169.22 62.22 176.25 56 180 56s10.81 6.22 17.29 19.89c11.1 23.41 15.29 58 3.89 76.11h-42.33c-11.4-18.1-7.21-52.7 3.89-76.11Z" />
</g>
</svg>
</span>
<span className={styles.name}>Steps</span>
<b className={`${styles.pct} ${styles.pctMove}`} />
</li>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.icBurn}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path
d="M208 144a80 80 0 0 1-77.94 80A40 40 0 0 0 168 184c0-40-40-64-40-64s-40 24-40 64a40 40 0 0 0 37.94 40A80 80 0 0 1 48 144c0-72 80-120 80-120s80 48 80 120"
opacity=".2"
/>
<path d="M173.79 51.48a221.3 221.3 0 0 0-41.67-34.34a8 8 0 0 0-8.24 0a221.3 221.3 0 0 0-41.67 34.34C54.59 80.48 40 112.47 40 144a88 88 0 0 0 176 0c0-31.53-14.59-63.52-42.21-92.52M96 184c0-27.67 22.53-47.28 32-54.3c9.48 7 32 26.63 32 54.3a32 32 0 0 1-64 0m77.27 15.93A47.8 47.8 0 0 0 176 184c0-44-42.09-69.79-43.88-70.86a8 8 0 0 0-8.24 0C122.09 114.21 80 140 80 184a47.8 47.8 0 0 0 2.73 15.93A71.88 71.88 0 0 1 56 144c0-34.41 20.4-63.15 37.52-81.19A216.2 216.2 0 0 1 128 33.54a215.8 215.8 0 0 1 34.48 29.27C193.49 95.5 200 125 200 144a71.88 71.88 0 0 1-26.73 55.93" />
</g>
</svg>
</span>
<span className={styles.name}>Move</span>
<b className={`${styles.pct} ${styles.pctBurn}`} />
</li>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.icRest}`} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path
d="M210.69 158.18A88 88 0 1 1 97.82 45.31A96.08 96.08 0 0 0 192 160a97 97 0 0 0 18.69-1.82"
opacity=".2"
/>
<path d="M240 96a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16V72a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-96-40h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16m72.77 97a8 8 0 0 1 1.43 8A96 96 0 1 1 95.07 37.8a8 8 0 0 1 10.6 9.06a88.07 88.07 0 0 0 103.47 103.47a8 8 0 0 1 7.63 2.67m-19.39 14.88c-1.79.09-3.59.14-5.38.14A104.11 104.11 0 0 1 88 64c0-1.79 0-3.59.14-5.38a80 80 0 1 0 109.24 109.24Z" />
</g>
</svg>
</span>
<span className={styles.name}>Sleep</span>
<b className={`${styles.pct} ${styles.pctRest}`} />
</li>
</ul>
<button
type="button"
className={styles.btn}
onClick={() => setP([roll(), roll(), roll()])}
>
Refill
</button>
</div>
</div>
);
}01 Triple activity rings — Measured area 7.207% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · 9 SVG Progress Ring Widgets, Math Included
The post this demo lives in
9 SVG Progress Ring Widgets, Math IncludedAn SVG progress ring fills a circle instead of a bar, and one <circle> with two attributes gets you there: stroke-dashar See all demos in this post

