05三重ゴールリング
水・運動・読書の3つのリングがそれぞれ stroke-dashoffset で巻かれ、3つとも閉じると破片が一度はじけます。リングごとに数字を添えているので色がなくても読めます。
使いどころヘルスアプリのホームの1日サマリー、ダッシュボード上部のKPIウィジェット
stroke-dasharraystroke-dashoffsetstroke-linecapprefers-reduced-motion
<main class="stage">
<div class="gr is-demo" id="gr">
<div class="gr__main">
<div class="gr__ringBox">
<svg class="gr__svg" viewBox="0 0 128 128" aria-hidden="true">
<circle class="gr__track" cx="64" cy="64" r="50"/>
<circle class="gr__track" cx="64" cy="64" r="38"/>
<circle class="gr__track" cx="64" cy="64" r="26"/>
<circle class="gr__fill gr__fill--a" id="ring0" cx="64" cy="64" r="50"/>
<circle class="gr__fill gr__fill--b" id="ring1" cx="64" cy="64" r="38"/>
<circle class="gr__fill gr__fill--c" id="ring2" cx="64" cy="64" r="26"/>
</svg>
<span class="gr__burst" aria-hidden="true">
<i class="gr__shard" style="--a:0"></i><i class="gr__shard" style="--a:1"></i>
<i class="gr__shard" style="--a:2"></i><i class="gr__shard" style="--a:3"></i>
<i class="gr__shard" style="--a:4"></i><i class="gr__shard" style="--a:5"></i>
<i class="gr__shard" style="--a:6"></i><i class="gr__shard" style="--a:7"></i>
</span>
</div>
<ul class="gr__legend">
<li class="gr__item">
<i class="gr__swatch gr__swatch--a" aria-hidden="true"></i>
<span class="gr__name">水を飲む</span>
<b class="gr__pct" id="pct0">75%</b>
</li>
<li class="gr__item">
<i class="gr__swatch gr__swatch--b" aria-hidden="true"></i>
<span class="gr__name">運動30分</span>
<b class="gr__pct" id="pct1">67%</b>
</li>
<li class="gr__item">
<i class="gr__swatch gr__swatch--c" aria-hidden="true"></i>
<span class="gr__name">読書20ページ</span>
<b class="gr__pct" id="pct2">50%</b>
</li>
</ul>
</div>
<p class="gr__say" id="say" role="status" aria-live="polite">3つの目標のうち0個達成</p>
<div class="gr__bar" id="bar">
<button class="gr__go" type="button" data-k="0">水を1杯</button>
<button class="gr__go" type="button" data-k="1">運動10分</button>
<button class="gr__go" type="button" data-k="2">5ページ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 삼중 목표 링 — 반지름이 다른 세 원이 각자 stroke-dasharray(둘레) 와 stroke-dashoffset(남은 길이)으로 감긴다.
// 링 끝을 stroke-linecap: round 로 둥글려 0% 에서도 점 하나가 남아 "빈 링" 과 "끊긴 링" 이 구분된다.
// 색만으로 읽히지 않게 링마다 이름과 퍼센트 숫자를 옆에 붙였다 (WCAG 1.4.1).
// 셋이 다 닫히면 조각 8개가 한 번 터진다 — 미리보기는 이 장면을 2초 안에 압축해 재생하고,
// 사람이 버튼을 누르면 JS 가 .is-demo 를 떼고 .is-done 을 잠깐 붙여 같은 폭발을 한 번만 돌린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.gr { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.gr__main { display: flex; align-items: center; gap: $sp-4; }
.gr__ringBox { position: relative; display: grid; place-items: center; width: 128px; height: 128px; flex: 0 0 auto; }
.gr__svg { width: 128px; height: 128px; transform: rotate(-90deg); }
.gr__track { fill: none; stroke: rgba($subject-cream, .12); stroke-width: 10; }
.gr__fill { fill: none; stroke-width: 10; stroke-linecap: round; stroke-dasharray: 314.16; stroke-dashoffset: 78; }
.gr__fill--a { stroke: $color; }
.gr__fill--b { stroke: $subject-mint; stroke-dasharray: 238.76; stroke-dashoffset: 79; }
.gr__fill--c { stroke: $subject-pink; stroke-dasharray: 163.36; stroke-dashoffset: 82; }
.gr__burst { position: absolute; width: 0; height: 0; }
.gr__shard {
position: absolute; left: -3px; top: -3px; width: 6px; height: 6px; border-radius: $r-xs;
background: $subject-cream; opacity: 0;
}
.gr__legend { display: flex; flex-direction: column; gap: $sp-2; margin: 0; padding: 0; list-style: none; flex: 1 1 auto; min-width: 0; }
.gr__item { display: flex; align-items: center; gap: $sp-2; }
.gr__swatch { width: 10px; height: 10px; border-radius: $r-xs; flex: 0 0 auto; }
.gr__swatch--a { background: $color; }
.gr__swatch--b { background: $subject-mint; }
.gr__swatch--c { background: $subject-pink; }
.gr__name {
flex: 1 1 auto; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); color: $subject-cream;
overflow: hidden; white-space: nowrap;
}
.gr__pct { font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); color: $subject-cream; }
.gr__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .7); }
.gr__bar { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.gr__go {
border: 2px solid rgba($subject-cream, .38); border-radius: $r-pill; padding: $sp-1 $sp-3;
background: transparent; color: $subject-cream; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.gr__go:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 ───────────────────────────────────────────────
// 세 링이 서로 다른 속도로 감겨 66% 에 함께 닫히고, 그 순간 조각이 터진 뒤 88% 안에 처음으로 돌아온다.
.gr.is-demo .gr__fill--a {
animation-name: grRingA; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--b {
animation-name: grRingB; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--c {
animation-name: grRingC; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes grRingA {
0% { stroke-dashoffset: 314.16; }
66% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 314.16; }
100% { stroke-dashoffset: 314.16; }
}
@keyframes grRingB {
0% { stroke-dashoffset: 238.76; }
66% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 238.76; }
100% { stroke-dashoffset: 238.76; }
}
@keyframes grRingC {
0% { stroke-dashoffset: 163.36; }
66% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 163.36; }
100% { stroke-dashoffset: 163.36; }
}
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard {
animation-name: grBurst; animation-duration: $duration; animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.gr.is-done .gr__shard { animation-duration: $dur-enter; animation-iteration-count: 1; }
@keyframes grBurst {
0%, 66% { transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(.4); opacity: 0; }
72% { transform: rotate(calc(var(--a) * 45deg)) translateY(-34px) scale(1); opacity: 1; }
84% { transform: rotate(calc(var(--a) * 45deg)) translateY(-62px) scale(.4); opacity: 0; }
100% { transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(.4); opacity: 0; }
}
@media (max-width: 340px) {
.gr { gap: $sp-2; }
.gr__main { gap: $sp-2; }
.gr__ringBox { width: 96px; height: 96px; }
.gr__svg { width: 96px; height: 96px; }
.gr__legend { gap: $sp-1; }
}
// reduce 에서는 폭발을 아예 끄고 링도 지금 값에서 멈춘다 — 터지는 조각은 반짝임이라 먼저 버린다
@media (prefers-reduced-motion: reduce) {
.gr.is-demo .gr__fill--a,
.gr.is-demo .gr__fill--b,
.gr.is-demo .gr__fill--c { animation: none !important; }
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard { animation: none !important; opacity: 0 !important; }
}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);
}
.gr {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.gr__main {
display: flex;
align-items: center;
gap: 16px;
}
.gr__ringBox {
position: relative;
display: grid;
place-items: center;
width: 128px;
height: 128px;
flex: 0 0 auto;
}
.gr__svg {
width: 128px;
height: 128px;
transform: rotate(-90deg);
}
.gr__track {
fill: none;
stroke: rgba(255, 247, 230, 0.12);
stroke-width: 10;
}
.gr__fill {
fill: none;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 314.16;
stroke-dashoffset: 78;
}
.gr__fill--a {
stroke: #ffd23f;
}
.gr__fill--b {
stroke: #4cd4a6;
stroke-dasharray: 238.76;
stroke-dashoffset: 79;
}
.gr__fill--c {
stroke: #ff9ebb;
stroke-dasharray: 163.36;
stroke-dashoffset: 82;
}
.gr__burst {
position: absolute;
width: 0;
height: 0;
}
.gr__shard {
position: absolute;
left: -3px;
top: -3px;
width: 6px;
height: 6px;
border-radius: 4px;
background: #fff7e6;
opacity: 0;
}
.gr__legend {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
flex: 1 1 auto;
min-width: 0;
}
.gr__item {
display: flex;
align-items: center;
gap: 8px;
}
.gr__swatch {
width: 10px;
height: 10px;
border-radius: 4px;
flex: 0 0 auto;
}
.gr__swatch--a {
background: #ffd23f;
}
.gr__swatch--b {
background: #4cd4a6;
}
.gr__swatch--c {
background: #ff9ebb;
}
.gr__name {
flex: 1 1 auto;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
color: #fff7e6;
overflow: hidden;
white-space: nowrap;
}
.gr__pct {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
color: #fff7e6;
}
.gr__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.7);
}
.gr__bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.gr__go {
border: 2px solid rgba(255, 247, 230, 0.38);
border-radius: 999px;
padding: 4px 12px;
background: transparent;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.gr__go:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.gr.is-demo .gr__fill--a {
animation-name: grRingA;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--b {
animation-name: grRingB;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--c {
animation-name: grRingC;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes grRingA {
0% {
stroke-dashoffset: 314.16;
}
66% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 314.16;
}
100% {
stroke-dashoffset: 314.16;
}
}
@keyframes grRingB {
0% {
stroke-dashoffset: 238.76;
}
66% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 238.76;
}
100% {
stroke-dashoffset: 238.76;
}
}
@keyframes grRingC {
0% {
stroke-dashoffset: 163.36;
}
66% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 163.36;
}
100% {
stroke-dashoffset: 163.36;
}
}
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard {
animation-name: grBurst;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.gr.is-done .gr__shard {
animation-duration: 600ms;
animation-iteration-count: 1;
}
@keyframes grBurst {
0%, 66% {
transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(0.4);
opacity: 0;
}
72% {
transform: rotate(calc(var(--a) * 45deg)) translateY(-34px) scale(1);
opacity: 1;
}
84% {
transform: rotate(calc(var(--a) * 45deg)) translateY(-62px) scale(0.4);
opacity: 0;
}
100% {
transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(0.4);
opacity: 0;
}
}
@media (max-width: 340px) {
.gr {
gap: 8px;
}
.gr__main {
gap: 8px;
}
.gr__ringBox {
width: 96px;
height: 96px;
}
.gr__svg {
width: 96px;
height: 96px;
}
.gr__legend {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.gr.is-demo .gr__fill--a,
.gr.is-demo .gr__fill--b,
.gr.is-demo .gr__fill--c {
animation: none !important;
}
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard {
animation: none !important;
opacity: 0 !important;
}
}var STORE = 'godrich-habit-rings';
var GOALS = [4, 3, 2];
var RADII = [50, 38, 26];
var SAY_TEXT = '3つの目標のうち{n}個達成';
var root = document.getElementById('gr');
var rings = [document.getElementById('ring0'), document.getElementById('ring1'), document.getElementById('ring2')];
var pcts = [document.getElementById('pct0'), document.getElementById('pct1'), document.getElementById('pct2')];
var sayEl = document.getElementById('say');
var done = [3, 2, 1];
var burstTimer = 0;
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { done = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(done)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function paint() {
var closed = 0;
for (var i = 0; i < rings.length; i++) {
var p = Math.min(done[i] / GOALS[i], 1);
var circ = 2 * Math.PI * RADII[i]; // 링마다 반지름이 다르니 둘레도 따로 잰다
rings[i].style.strokeDasharray = String(circ);
rings[i].style.strokeDashoffset = String(circ * (1 - p));
pcts[i].textContent = Math.round(p * 100) + '%';
if (p >= 1) { closed++; }
}
sayEl.textContent = SAY_TEXT.replace('{n}', String(closed));
return closed === rings.length;
}
function burst() {
root.classList.remove('is-done');
// 클래스를 뗀 직후 다시 붙이면 같은 애니메이션이 처음부터 다시 돈다
void root.offsetWidth;
root.classList.add('is-done');
window.clearTimeout(burstTimer);
burstTimer = window.setTimeout(function () { root.classList.remove('is-done'); }, 900);
}
load();
paint();
document.getElementById('bar').addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.gr__go') : null;
if (!b) { return; }
root.classList.remove('is-demo');
var k = Number(b.getAttribute('data-k'));
done[k] = done[k] >= GOALS[k] ? 0 : done[k] + 1;
save();
if (paint()) { burst(); }
});// GoalRings — vanilla 판(run/297/demo/05-habit-goal-rings/index.html) 의 React 포트.
// 반지름이 다른 세 링을 각자의 둘레(2πr)로 감고, 셋이 다 닫히면 조각을 한 번만 터뜨린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GoalRings.module.scss";
export type GoalRingsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ShardVars = CSSProperties & { "--a"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const STORE = "godrich-habit-rings";
const GOALS = [4, 3, 2];
const RADII = [50, 38, 26];
const NAMES = ["물 마시기", "운동 30분", "독서 20쪽"];
const BUTTONS = ["물 한 잔", "운동 10분", "책 5쪽"];
const SAY_TEXT = "세 목표 중 {n}개 달성";
const SHARDS = [0, 1, 2, 3, 4, 5, 6, 7];
export default function GoalRings({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: GoalRingsProps) {
const [done, setDone] = useState<number[]>([3, 2, 1]);
const [demo, setDemo] = useState(true);
const [burst, setBurst] = useState(false);
const timerRef = useRef<number>(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setDone(JSON.parse(raw) as number[]); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
return () => { window.clearTimeout(timerRef.current); };
}, []);
function save(next: number[]) {
try { window.localStorage.setItem(STORE, JSON.stringify(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function bump(k: number) {
setDemo(false);
const next = done.map((v, i) => (i === k ? (v >= GOALS[i] ? 0 : v + 1) : v));
setDone(next);
save(next);
const closed = next.filter((v, i) => v >= GOALS[i]).length;
if (closed === GOALS.length) {
setBurst(false);
window.clearTimeout(timerRef.current);
// 다음 프레임에 다시 붙여야 같은 애니메이션이 처음부터 돈다
timerRef.current = window.setTimeout(() => {
setBurst(true);
timerRef.current = window.setTimeout(() => setBurst(false), 900);
}, 16);
}
}
const ratios = done.map((v, i) => Math.min(v / GOALS[i], 1));
const closed = ratios.filter((p) => p >= 1).length;
const fillClass = [styles.fillA, styles.fillB, styles.fillC];
const swatchClass = [styles.swatchA, styles.swatchB, styles.swatchC];
const cls = [styles.root, demo ? styles.isDemo : "", burst ? styles.isDone : ""].filter(Boolean).join(" ");
const shardVars = (a: number): ShardVars => ({ "--a": String(a) });
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.main}>
<div className={styles.ringBox}>
<svg className={styles.svg} viewBox="0 0 128 128" aria-hidden="true">
{RADII.map((r, i) => (
<circle className={styles.track} cx="64" cy="64" r={r} key={`t${i}`} />
))}
{RADII.map((r, i) => {
const circ = 2 * Math.PI * r; // 링마다 반지름이 다르니 둘레도 따로 잰다
return (
<circle
className={`${styles.fill} ${fillClass[i]}`}
cx="64"
cy="64"
r={r}
key={`f${i}`}
style={{ strokeDasharray: String(circ), strokeDashoffset: String(circ * (1 - ratios[i])) }}
/>
);
})}
</svg>
<span className={styles.burst} aria-hidden="true">
{SHARDS.map((a) => <i className={styles.shard} key={a} style={shardVars(a)} />)}
</span>
</div>
<ul className={styles.legend}>
{NAMES.map((n, i) => (
<li className={styles.item} key={i}>
<i className={`${styles.swatch} ${swatchClass[i]}`} aria-hidden="true" />
<span className={styles.name}>{n}</span>
<b className={styles.pct}>{Math.round(ratios[i] * 100)}%</b>
</li>
))}
</ul>
</div>
<p className={styles.say} role="status" aria-live="polite">
{SAY_TEXT.replace("{n}", String(closed))}
</p>
<div className={styles.bar}>
{BUTTONS.map((label, i) => (
<button className={styles.go} type="button" key={i} onClick={() => bump(i)}>{label}</button>
))}
</div>
</div>
</main>
);
}05 三重ゴールリング — 計測 累積面積 5.603% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · 習慣トラッカー UI 9選 — 草グリッドから共有カードまで


