08比較リングペア(今週/先週)
今週と先週のリングが並んで埋まり、間の差分の数字が色で増減を示し、「週を進める」を押すと今週が先週の位置へ実際に移ります。
使いどころ週次レポート・リテンション比較のように2期間を一目で並べたいカード
2 ringsdeltaclick

<main class="stage">
<div class="bp is-demo">
<div class="bp__pair">
<figure class="bp__cell bp__cell--now">
<div class="bp__dial">
<svg class="bp__svg" viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle class="bp__track" cx="44" cy="44" r="36"/>
<circle class="bp__ring" cx="44" cy="44" r="36"/>
</svg>
<span class="bp__num" role="status" aria-label="今週の達成率"></span>
</div>
<figcaption class="bp__cap">今週</figcaption>
</figure>
<span class="bp__delta">
<span class="bp__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v64l-64-64Z" opacity=".2"/><path d="M232 48h-64a8 8 0 0 0-5.66 13.66L188.69 88L136 140.69l-34.34-34.35a8 8 0 0 0-11.32 0l-72 72a8 8 0 0 0 11.32 11.32L96 123.31l34.34 34.35a8 8 0 0 0 11.32 0L200 99.31l26.34 26.35A8 8 0 0 0 240 120V56a8 8 0 0 0-8-8m-8 52.69L187.31 64H224Z"/></g></svg></span>
</span>
<figure class="bp__cell bp__cell--prev">
<div class="bp__dial">
<svg class="bp__svg" viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle class="bp__track" cx="44" cy="44" r="36"/>
<circle class="bp__ring" cx="44" cy="44" r="36"/>
</svg>
<span class="bp__num" aria-hidden="true"></span>
</div>
<figcaption class="bp__cap">先週</figcaption>
</figure>
</div>
<button class="bp__btn" type="button">週を進める</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
@property --a { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --b { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --d { syntax: "<integer>"; inherits: true; initial-value: 0; }
.bp {
--a: 72;
--b: 58;
--d: 14;
box-sizing: border-box;
display: grid;
justify-items: center;
gap: $sp-3;
color: $stage-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
transition: --a $dur-enter $easing, --b $dur-enter $easing, --d $dur-enter $easing;
}
.bp__pair { display: flex; align-items: center; gap: $sp-2; }
.bp__cell { margin: 0; display: grid; justify-items: center; gap: $sp-1; }
.bp__dial { position: relative; display: grid; place-items: center; }
.bp__svg { width: clamp(72px, 23vw, 88px); height: clamp(72px, 23vw, 88px); transform: rotate(-90deg); }
.bp__track { fill: none; stroke: rgba(23, 20, 26, .12); stroke-width: 10; }
.bp__ring {
fill: none;
stroke-width: 10;
stroke-linecap: round;
// r=36 → C = 2π × 36 = 226.195. 두 링이 같은 반지름이라 차이가 각도로 바로 보인다
stroke-dasharray: 226.195;
}
.bp__cell--now .bp__ring { stroke: $color; stroke-dashoffset: calc(226.195 * (1 - var(--a) / 100)); }
.bp__cell--prev .bp__ring { stroke: rgba(23, 20, 26, .38); stroke-dashoffset: calc(226.195 * (1 - var(--b) / 100)); }
.bp__num {
position: absolute;
font-size: clamp(15px, 4.6vw, 17px);
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bp__cell--now .bp__num::after { counter-reset: n var(--a); content: counter(n) "%"; }
.bp__cell--prev .bp__num::after { counter-reset: n var(--b); content: counter(n) "%"; }
.bp__cell--prev .bp__num { color: rgba(23, 20, 26, .5); }
.bp__cap { font-size: 10px; font-weight: 700; color: rgba(23, 20, 26, .72); }
.bp__delta {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .12);
color: $color;
font-size: 11px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bp__ic { display: inline-flex; }
.bp__delta::after { counter-reset: d var(--d); content: "+" counter(d) "%"; }
.bp.is-down .bp__delta { background: $error-soft; color: $error; }
.bp.is-down .bp__ic { transform: scaleY(-1); }
.bp.is-down .bp__delta::after { content: "−" counter(d) "%"; }
.bp__btn {
border: 0;
border-radius: $r-pill;
background: $stage-ink;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
padding: $sp-2 $sp-4;
cursor: pointer;
transition: transform $dur-quick $ease-pop;
}
.bp__btn:hover, .bp__btn:focus-visible { transform: translateY(-2px); }
// ── 자동 루프: 두 링이 각자 목표까지 감겼다 같이 풀린다. 차이 숫자도 함께 자란다 ──
.bp.is-demo { animation: bp-fill $duration $easing infinite; }
@keyframes bp-fill {
0% { --a: 0; --b: 0; --d: 0; }
45%, 55% { --a: 72; --b: 58; --d: 14; }
100% { --a: 0; --b: 0; --d: 0; }
}
@media (max-width: 340px) {
.bp__delta { font-size: 10px; padding: $sp-1; }
.bp__pair { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.bp.is-demo { animation: none !important; }
.bp { transition: none; }
.bp__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --a {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --b {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --d {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.bp {
--a: 72;
--b: 58;
--d: 14;
box-sizing: border-box;
display: grid;
justify-items: center;
gap: 12px;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
transition: --a 600ms cubic-bezier(0.2, 0.8, 0.2, 1), --b 600ms cubic-bezier(0.2, 0.8, 0.2, 1), --d 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bp__pair {
display: flex;
align-items: center;
gap: 8px;
}
.bp__cell {
margin: 0;
display: grid;
justify-items: center;
gap: 4px;
}
.bp__dial {
position: relative;
display: grid;
place-items: center;
}
.bp__svg {
width: clamp(72px, 23vw, 88px);
height: clamp(72px, 23vw, 88px);
transform: rotate(-90deg);
}
.bp__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 10;
}
.bp__ring {
fill: none;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 226.195;
}
.bp__cell--now .bp__ring {
stroke: #2f6df6;
stroke-dashoffset: calc(226.195 * (1 - var(--a) / 100));
}
.bp__cell--prev .bp__ring {
stroke: rgba(23, 20, 26, 0.38);
stroke-dashoffset: calc(226.195 * (1 - var(--b) / 100));
}
.bp__num {
position: absolute;
font-size: clamp(15px, 4.6vw, 17px);
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bp__cell--now .bp__num::after {
counter-reset: n var(--a);
content: counter(n) "%";
}
.bp__cell--prev .bp__num::after {
counter-reset: n var(--b);
content: counter(n) "%";
}
.bp__cell--prev .bp__num {
color: rgba(23, 20, 26, 0.5);
}
.bp__cap {
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.bp__delta {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
font-size: 11px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bp__ic {
display: inline-flex;
}
.bp__delta::after {
counter-reset: d var(--d);
content: "+" counter(d) "%";
}
.bp.is-down .bp__delta {
background: #ffe0d8;
color: #c2361a;
}
.bp.is-down .bp__ic {
transform: scaleY(-1);
}
.bp.is-down .bp__delta::after {
content: "−" counter(d) "%";
}
.bp__btn {
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
padding: 8px 16px;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.bp__btn:hover, .bp__btn:focus-visible {
transform: translateY(-2px);
}
.bp.is-demo {
animation: bp-fill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bp-fill {
0% {
--a: 0;
--b: 0;
--d: 0;
}
45%, 55% {
--a: 72;
--b: 58;
--d: 14;
}
100% {
--a: 0;
--b: 0;
--d: 0;
}
}
@media (max-width: 340px) {
.bp__delta {
font-size: 10px;
padding: 4px;
}
.bp__pair {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp.is-demo {
animation: none !important;
}
.bp {
transition: none;
}
.bp__btn {
transition: none;
}
}// 실제 동작: 今週 값이 先週 자리로 밀려가고 새 값이 들어온다. 차이의 부호는 is-down 이 맡는다.
var bp = document.querySelector('.bp');
var now = 72, prev = 58;
bp.querySelector('.bp__btn').addEventListener('click', function () {
bp.classList.remove('is-demo');
prev = now;
now = 20 + Math.floor(Math.random() * 80);
bp.style.setProperty('--a', String(now));
bp.style.setProperty('--b', String(prev));
bp.style.setProperty('--d', String(Math.abs(now - prev)));
bp.classList.toggle('is-down', now < prev);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CompareRingPair.module.scss";
export type CompareRingPairProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--a"?: string;
"--b"?: string;
"--d"?: string;
};
export default function CompareRingPair({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CompareRingPairProps) {
const [pair, setPair] = useState<{ now: number; prev: number }>({ now: 72, prev: 58 });
// this week's value slides into last week's slot and a new one comes in
const nextWeek = () => {
setPair((p) => ({ prev: p.now, now: 20 + Math.floor(Math.random() * 80) }));
};
const down = pair.now < pair.prev;
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--a": String(pair.now),
"--b": String(pair.prev),
"--d": String(Math.abs(pair.now - pair.prev)),
};
return (
<div className={`${styles.bp} ${down ? styles.isDown : ""}`} style={vars}>
<div className={styles.pair}>
<figure className={`${styles.cell} ${styles.cellNow}`}>
<div className={styles.dial}>
<svg className={styles.svg} viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle className={styles.track} cx="44" cy="44" r="36" />
<circle className={styles.ring} cx="44" cy="44" r="36" />
</svg>
<span className={styles.num} role="status" aria-label="This week's rate" />
</div>
<figcaption className={styles.cap}>This week</figcaption>
</figure>
<span className={styles.delta}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M232 56v64l-64-64Z" opacity=".2" />
<path d="M232 48h-64a8 8 0 0 0-5.66 13.66L188.69 88L136 140.69l-34.34-34.35a8 8 0 0 0-11.32 0l-72 72a8 8 0 0 0 11.32 11.32L96 123.31l34.34 34.35a8 8 0 0 0 11.32 0L200 99.31l26.34 26.35A8 8 0 0 0 240 120V56a8 8 0 0 0-8-8m-8 52.69L187.31 64H224Z" />
</g>
</svg>
</span>
</span>
<figure className={`${styles.cell} ${styles.cellPrev}`}>
<div className={styles.dial}>
<svg className={styles.svg} viewBox="0 0 88 88" width="88" height="88" aria-hidden="true">
<circle className={styles.track} cx="44" cy="44" r="36" />
<circle className={styles.ring} cx="44" cy="44" r="36" />
</svg>
<span className={styles.num} aria-hidden="true" />
</div>
<figcaption className={styles.cap}>Last week</figcaption>
</figure>
</div>
<button className={styles.btn} type="button" onClick={nextWeek}>
Next week
</button>
</div>
);
}08 比較リングペア(今週/先週) — 計測 累積面積 4.356% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · 円形プログレスバー css スタットリング9選 — 数値も実際に動く
このデモが属する記事
円形プログレスバー css スタットリング9選 — 数値も実際に動く円形プログレスバー css とは、棒の代わりに円に沿って値の分だけ埋めていく進捗表示で、SVGの円ひとつに stroke-dasharray と stroke-dashoffset の二つを与えるだけで作れます。九つとも、ボタンやスライダー この記事のデモをすべて見る


