09リング進捗カードリスト
小さなリング3つがリストの各行に付いて順に埋まり、行をクリックするとその行のリングとパーセントだけが実際に10ずつ増えます。
使いどころプロジェクト別進捗・メンバー別目標のように複数項目を縦に並べるサイドパネル
r=14animation-delayclick

<main class="stage">
<ul class="pl is-demo">
<li class="pl__row" style="--v:78">
<button class="pl__hit" type="button">
<span class="pl__dial">
<svg class="pl__svg" viewBox="0 0 36 36" width="36" height="36" aria-hidden="true">
<circle class="pl__track" cx="18" cy="18" r="14"/>
<circle class="pl__ring" cx="18" cy="18" r="14"/>
</svg>
<span class="pl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2"/><path d="M221.87 83.16A104.1 104.1 0 1 1 195.67 49l22.67-22.68a8 8 0 0 1 11.32 11.32l-96 96a8 8 0 0 1-11.32-11.32l27.72-27.72a40 40 0 1 0 17.87 31.09a8 8 0 1 1 16-.9a56 56 0 1 1-22.38-41.65l22.75-22.75a87.88 87.88 0 1 0 23.13 29.67a8 8 0 0 1 14.44-6.9"/></g></svg></span>
</span>
<span class="pl__name">デザインシステム</span>
<span class="pl__pct" role="status"></span>
</button>
</li>
<li class="pl__row" style="--v:52">
<button class="pl__hit" type="button">
<span class="pl__dial">
<svg class="pl__svg" viewBox="0 0 36 36" width="36" height="36" aria-hidden="true">
<circle class="pl__track" cx="18" cy="18" r="14"/>
<circle class="pl__ring" cx="18" cy="18" r="14"/>
</svg>
<span class="pl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></svg></span>
</span>
<span class="pl__name">ドキュメント整理</span>
<span class="pl__pct" role="status"></span>
</button>
</li>
<li class="pl__row" style="--v:34">
<button class="pl__hit" type="button">
<span class="pl__dial">
<svg class="pl__svg" viewBox="0 0 36 36" width="36" height="36" aria-hidden="true">
<circle class="pl__track" cx="18" cy="18" r="14"/>
<circle class="pl__ring" cx="18" cy="18" r="14"/>
</svg>
<span class="pl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M72 144H32a8 8 0 0 1 0-16h35.72l13.62-20.44a8 8 0 0 1 13.32 0l25.34 38l9.34-14A8 8 0 0 1 136 128h24a8 8 0 0 1 0 16h-19.72l-13.62 20.44a8 8 0 0 1-13.32 0L88 126.42l-9.34 14A8 8 0 0 1 72 144M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62v2.25a8 8 0 1 0 16-.5V102a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8c-10.83-6.31-42.63-26-66.68-52.21a8 8 0 1 0-11.8 10.82c31.17 34 72.93 56.68 74.69 57.63a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62"/></g></svg></span>
</span>
<span class="pl__name">オンボーディング動画</span>
<span class="pl__pct" role="status"></span>
</button>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
@property --v { syntax: "<integer>"; inherits: true; initial-value: 0; }
.pl {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0;
width: min(228px, 86vw);
display: grid;
gap: $sp-2;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pl__row {
border-radius: $r-card;
background: rgba(255, 247, 230, .07);
transition: --v $dur-enter $easing, background $dur-quick $ease-out;
}
.pl__row:hover { background: rgba(255, 247, 230, .14); }
.pl__hit {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
border: 0;
background: transparent;
color: inherit;
font-family: inherit;
padding: $sp-2 $sp-3;
cursor: pointer;
border-radius: inherit;
text-align: left;
}
.pl__dial { position: relative; display: grid; place-items: center; flex: 0 0 auto; }
.pl__svg { width: 36px; height: 36px; transform: rotate(-90deg); }
.pl__track { fill: none; stroke: rgba(255, 247, 230, .16); stroke-width: 4; }
.pl__ring {
fill: none;
stroke: $color;
stroke-width: 4;
stroke-linecap: round;
// r=14 → C = 2π × 14 = 87.965. 작은 링일수록 stroke-width 를 같이 줄여야 안이 안 막힌다
stroke-dasharray: 87.965;
stroke-dashoffset: calc(87.965 * (1 - var(--v) / 100));
}
.pl__ic { position: absolute; display: inline-flex; color: rgba(255, 247, 230, .6); }
.pl__name { flex: 1 1 auto; font-size: 12px; font-weight: 700; }
.pl__pct {
font-size: 12px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: $color;
}
.pl__pct::after { counter-reset: v var(--v); content: counter(v) "%"; }
// ── 자동 루프: 줄마다 목표가 달라서 keyframes 도 줄마다 따로. 0 에서 시작해 0 으로 돌아온다 ──
$goals: 78, 52, 34;
@each $g in $goals {
$i: index($goals, $g);
.pl.is-demo .pl__row:nth-child(#{$i}) {
animation: pl-fill-#{$i} $duration $easing infinite;
animation-delay: #{($i - 1) * 0.12}s;
}
@keyframes pl-fill-#{$i} {
0% { --v: 0; }
45%, 55% { --v: #{$g}; }
100% { --v: 0; }
}
}
@media (max-width: 340px) {
.pl__hit { gap: $sp-2; }
.pl__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .pl__row { animation: none !important; }
.pl__row { 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 --v {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl {
box-sizing: border-box;
list-style: none;
margin: 0;
padding: 0;
width: min(228px, 86vw);
display: grid;
gap: 8px;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.pl__row {
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
transition: --v 600ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms ease-out;
}
.pl__row:hover {
background: rgba(255, 247, 230, 0.14);
}
.pl__hit {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
border: 0;
background: transparent;
color: inherit;
font-family: inherit;
padding: 8px 12px;
cursor: pointer;
border-radius: inherit;
text-align: left;
}
.pl__dial {
position: relative;
display: grid;
place-items: center;
flex: 0 0 auto;
}
.pl__svg {
width: 36px;
height: 36px;
transform: rotate(-90deg);
}
.pl__track {
fill: none;
stroke: rgba(255, 247, 230, 0.16);
stroke-width: 4;
}
.pl__ring {
fill: none;
stroke: #4cd4a6;
stroke-width: 4;
stroke-linecap: round;
stroke-dasharray: 87.965;
stroke-dashoffset: calc(87.965 * (1 - var(--v) / 100));
}
.pl__ic {
position: absolute;
display: inline-flex;
color: rgba(255, 247, 230, 0.6);
}
.pl__name {
flex: 1 1 auto;
font-size: 12px;
font-weight: 700;
}
.pl__pct {
font-size: 12px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #4cd4a6;
}
.pl__pct::after {
counter-reset: v var(--v);
content: counter(v) "%";
}
.pl.is-demo .pl__row:nth-child(1) {
animation: pl-fill-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0s;
}
@keyframes pl-fill-1 {
0% {
--v: 0;
}
45%, 55% {
--v: 78;
}
100% {
--v: 0;
}
}
.pl.is-demo .pl__row:nth-child(2) {
animation: pl-fill-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0.12s;
}
@keyframes pl-fill-2 {
0% {
--v: 0;
}
45%, 55% {
--v: 52;
}
100% {
--v: 0;
}
}
.pl.is-demo .pl__row:nth-child(3) {
animation: pl-fill-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0.24s;
}
@keyframes pl-fill-3 {
0% {
--v: 0;
}
45%, 55% {
--v: 34;
}
100% {
--v: 0;
}
}
@media (max-width: 340px) {
.pl__hit {
gap: 8px;
}
.pl__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .pl__row {
animation: none !important;
}
.pl__row {
transition: none;
}
}// 실제 동작: 줄을 누르면 그 줄의 --v 만 10 올라간다. 링과 퍼센트가 같은 값을 읽으므로 둘이 어긋나지 않는다.
var pl = document.querySelector('.pl');
pl.querySelectorAll('.pl__row').forEach(function (row) {
row.querySelector('.pl__hit').addEventListener('click', function () {
pl.classList.remove('is-demo');
var v = Number(getComputedStyle(row).getPropertyValue('--v')) || 0;
row.style.setProperty('--v', String(v >= 100 ? 0 : Math.min(100, v + 10)));
});
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./RingProgressList.module.scss";
export type RingProgressListProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RowVars = CSSProperties & { "--v"?: string };
const ROWS: { name: string; icon: ReactNode }[] = [
{
name: "Design system",
icon: (
<svg viewBox="0 0 256 256" width="12" height="12">
<g fill="currentColor">
<path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2" />
<path d="M221.87 83.16A104.1 104.1 0 1 1 195.67 49l22.67-22.68a8 8 0 0 1 11.32 11.32l-96 96a8 8 0 0 1-11.32-11.32l27.72-27.72a40 40 0 1 0 17.87 31.09a8 8 0 1 1 16-.9a56 56 0 1 1-22.38-41.65l22.75-22.75a87.88 87.88 0 1 0 23.13 29.67a8 8 0 0 1 14.44-6.9" />
</g>
</svg>
),
},
{
name: "Docs cleanup",
icon: (
<svg viewBox="0 0 256 256" width="12" height="12">
<g fill="currentColor">
<path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2" />
<path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z" />
</g>
</svg>
),
},
{
name: "Onboarding video",
icon: (
<svg viewBox="0 0 256 256" width="12" height="12">
<g fill="currentColor">
<path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" />
<path d="M72 144H32a8 8 0 0 1 0-16h35.72l13.62-20.44a8 8 0 0 1 13.32 0l25.34 38l9.34-14A8 8 0 0 1 136 128h24a8 8 0 0 1 0 16h-19.72l-13.62 20.44a8 8 0 0 1-13.32 0L88 126.42l-9.34 14A8 8 0 0 1 72 144M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62v2.25a8 8 0 1 0 16-.5V102a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8c-10.83-6.31-42.63-26-66.68-52.21a8 8 0 1 0-11.8 10.82c31.17 34 72.93 56.68 74.69 57.63a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62" />
</g>
</svg>
),
},
];
export default function RingProgressList({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: RingProgressListProps) {
const [values, setValues] = useState<number[]>([78, 52, 34]);
// one click bumps only that row by 10 — ring and percentage read the same --v, so they cannot drift apart
const bump = (idx: number) => {
setValues((vs) => vs.map((v, i) => (i === idx ? (v >= 100 ? 0 : Math.min(100, v + 10)) : v)));
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.pl} style={vars}>
{ROWS.map((row, i) => {
const rowVars: RowVars = { "--v": String(values[i]) };
return (
<li key={row.name} className={styles.row} style={rowVars}>
<button className={styles.hit} type="button" onClick={() => bump(i)}>
<span className={styles.dial}>
<svg className={styles.svg} viewBox="0 0 36 36" width="36" height="36" aria-hidden="true">
<circle className={styles.track} cx="18" cy="18" r="14" />
<circle className={styles.ring} cx="18" cy="18" r="14" />
</svg>
<span className={styles.ic} aria-hidden="true">{row.icon}</span>
</span>
<span className={styles.name}>{row.name}</span>
<span className={styles.pct} role="status" />
</button>
</li>
);
})}
</ul>
);
}09 リング進捗カードリスト — 計測 累積面積 0.797% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · 円形プログレスバー css スタットリング9選 — 数値も実際に動く
このデモが属する記事
円形プログレスバー css スタットリング9選 — 数値も実際に動く円形プログレスバー css とは、棒の代わりに円に沿って値の分だけ埋めていく進捗表示で、SVGの円ひとつに stroke-dasharray と stroke-dashoffset の二つを与えるだけで作れます。九つとも、ボタンやスライダー この記事のデモをすべて見る

