03대기 링이 실제 구간으로 바뀌는 도넛 차트 로딩
점선 링이 도는 동안 기다리다가, 값이 오면 각 구간의 비율을 각도로 계산해 링의 색이 그 구간 색으로 나뉘어 채워진다.
쓰임완료율 도넛 차트가 집계되기 전 잠깐 도는 자리
--delaystroke-dasharraytransform-origin

<main class="stage">
<div class="cd is-demo" id="cd">
<svg class="cd__svg" id="cdSvg" viewBox="0 0 120 120" role="img" aria-label="완료율 도넛 그래프" aria-busy="true">
<circle class="cd__track" cx="60" cy="60" r="45" />
<circle class="cd__spin" cx="60" cy="60" r="45" />
<circle class="cd__seg" id="cdSeg0" cx="60" cy="60" r="45" data-i="0" />
<circle class="cd__seg" id="cdSeg1" cx="60" cy="60" r="45" data-i="1" />
<circle class="cd__seg" id="cdSeg2" cx="60" cy="60" r="45" data-i="2" />
<circle class="cd__seg" id="cdSeg3" cx="60" cy="60" r="45" data-i="3" />
</svg>
<button type="button" class="cd__reload" id="cdReload">완료율 다시 집계</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $chart-1;
@include stage($stage-ink);
.cd { width: min(180px, 60vw); text-align: center; }
.cd__svg { width: 100%; height: auto; transform: rotate(-90deg); }
.cd__track { fill: none; stroke: rgba($subject-cream, .14); stroke-width: 12; }
.cd__spin {
fill: none; stroke: rgba($subject-cream, .55); stroke-width: 4; stroke-linecap: round;
stroke-dasharray: 10 16; transform-origin: 60px 60px;
animation: cdSpin 1.4s linear infinite, cdSpinFade $duration $easing infinite;
}
.cd__seg {
fill: none; stroke-width: 12; stroke-linecap: butt; transform-origin: 60px 60px;
opacity: 0; transform: scale(.9); animation: cdSegCycle $duration $easing infinite;
}
#cdSeg0 { stroke: $chart-1; animation-delay: 0ms; }
#cdSeg1 { stroke: $chart-2; animation-delay: 130ms; }
#cdSeg2 { stroke: $chart-3; animation-delay: 230ms; }
#cdSeg3 { stroke: $chart-4; animation-delay: 310ms; }
.cd__reload {
display: block; margin: $sp-4 auto 0; appearance: none; border: none; cursor: pointer;
background: rgba($subject-cream, .12); color: $subject-cream; font-weight: 700; font-size: 13px;
padding: $sp-2 $sp-4; border-radius: $r-pill;
}
.cd__reload:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
@keyframes cdSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes cdSpinFade {
0% { opacity: 1; }
14% { opacity: 1; }
26% { opacity: 0; }
88% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes cdSegCycle {
0% { opacity: 0; transform: scale(.9); }
26% { opacity: 0; transform: scale(.9); }
52% { opacity: 1; transform: scale(1); }
88% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.9); }
}
@media (max-width: 340px) {
.cd { width: min(112px, 48vw); }
.cd__reload { margin-top: $sp-2; font-size: 11px; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 자동 루프를 멈추고 완성된 구간 색만 보인다
.cd__spin { animation: none; opacity: 0; }
.cd__seg { animation: none; opacity: 1; transform: scale(1); }
}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);
}
.cd {
width: min(180px, 60vw);
text-align: center;
}
.cd__svg {
width: 100%;
height: auto;
transform: rotate(-90deg);
}
.cd__track {
fill: none;
stroke: rgba(255, 247, 230, 0.14);
stroke-width: 12;
}
.cd__spin {
fill: none;
stroke: rgba(255, 247, 230, 0.55);
stroke-width: 4;
stroke-linecap: round;
stroke-dasharray: 10 16;
transform-origin: 60px 60px;
animation: cdSpin 1.4s linear infinite, cdSpinFade 2s ease-out infinite;
}
.cd__seg {
fill: none;
stroke-width: 12;
stroke-linecap: butt;
transform-origin: 60px 60px;
opacity: 0;
transform: scale(0.9);
animation: cdSegCycle 2s ease-out infinite;
}
#cdSeg0 {
stroke: #2f6df6;
animation-delay: 0ms;
}
#cdSeg1 {
stroke: #c2361a;
animation-delay: 130ms;
}
#cdSeg2 {
stroke: #2b9d74;
animation-delay: 230ms;
}
#cdSeg3 {
stroke: #8a5cd6;
animation-delay: 310ms;
}
.cd__reload {
display: block;
margin: 16px auto 0;
appearance: none;
border: none;
cursor: pointer;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 13px;
padding: 8px 16px;
border-radius: 999px;
}
.cd__reload:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@keyframes cdSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes cdSpinFade {
0% {
opacity: 1;
}
14% {
opacity: 1;
}
26% {
opacity: 0;
}
88% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes cdSegCycle {
0% {
opacity: 0;
transform: scale(0.9);
}
26% {
opacity: 0;
transform: scale(0.9);
}
52% {
opacity: 1;
transform: scale(1);
}
88% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.9);
}
}
@media (max-width: 340px) {
.cd {
width: min(112px, 48vw);
}
.cd__reload {
margin-top: 8px;
font-size: 11px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cd__spin {
animation: none;
opacity: 0;
}
.cd__seg {
animation: none;
opacity: 1;
transform: scale(1);
}
}(function () {
var VALUES = [35, 25, 22, 18];
var R = 45;
var CIRC = 2 * Math.PI * R;
var root = document.getElementById('cd');
var svg = document.getElementById('cdSvg');
var reload = document.getElementById('cdReload');
var segs = [0, 1, 2, 3].map(function (i) { return document.getElementById('cdSeg' + i); });
// 계산 겹: 각 구간의 비율을 원둘레 길이로 바꾸고, 시작 각도(누적 비율)로 순서를 정한다
function layout() {
var cum = 0;
VALUES.forEach(function (v, i) {
var frac = v / 100;
var segLen = CIRC * frac;
segs[i].style.strokeDasharray = segLen.toFixed(2) + ' ' + (CIRC - segLen).toFixed(2);
segs[i].style.strokeDashoffset = (-cum).toFixed(2);
segs[i].style.setProperty('--delay', (cum / CIRC * 60) + '%');
cum += segLen;
});
}
layout();
function human() {
root.classList.remove('is-demo');
segs.forEach(function (s) { s.style.opacity = '0'; s.style.transform = 'scale(.9)'; });
svg.setAttribute('aria-busy', 'true');
var cum = 0;
VALUES.forEach(function (v, i) {
var frac = v / 100;
var delayMs = (cum / CIRC) * 900;
cum += CIRC * frac;
window.setTimeout(function () {
segs[i].style.transition = 'opacity 260ms ease-out, transform 260ms ease-out';
segs[i].style.opacity = '1';
segs[i].style.transform = 'scale(1)';
}, 220 + delayMs);
});
window.setTimeout(function () { svg.setAttribute('aria-busy', 'false'); }, 220 + 900);
}
reload.addEventListener('click', human);
reload.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); }
});
}());import { useEffect, useRef } from 'react';
import styles from './ChartLoadingDonutRingMorph.module.scss';
const VALUES = [35, 25, 22, 18];
const R = 45;
const CIRC = 2 * Math.PI * R;
export default function ChartLoadingDonutRingMorph() {
const rootRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const reloadRef = useRef<HTMLButtonElement>(null);
const segRefs = useRef<SVGCircleElement[]>([]);
useEffect(() => {
const root = rootRef.current;
const svg = svgRef.current;
const reload = reloadRef.current;
if (!root || !svg || !reload) return undefined;
const segs = segRefs.current!;
if (!segs || segs.length !== VALUES.length) return undefined;
// 계산 겹: 각 구간의 비율을 원둘레 길이로 바꾸고, 시작 각도(누적 비율)로 순서를 정한다
function layout() {
let cum = 0;
VALUES.forEach((v, i) => {
const frac = v / 100;
const segLen = CIRC * frac;
segs[i].style.strokeDasharray = segLen.toFixed(2) + ' ' + (CIRC - segLen).toFixed(2);
segs[i].style.strokeDashoffset = (-cum).toFixed(2);
segs[i].style.setProperty('--delay', (cum / CIRC * 60) + '%');
cum += segLen;
});
}
layout();
const timers: number[] = [];
function human() {
root!.classList.remove(styles['is-demo']);
segs.forEach((s) => { s.style.opacity = '0'; s.style.transform = 'scale(.9)'; });
svg!.setAttribute('aria-busy', 'true');
let cum = 0;
VALUES.forEach((v, i) => {
const frac = v / 100;
const delayMs = (cum / CIRC) * 900;
cum += CIRC * frac;
timers.push(window.setTimeout(() => {
segs[i].style.transition = 'opacity 260ms ease-out, transform 260ms ease-out';
segs[i].style.opacity = '1';
segs[i].style.transform = 'scale(1)';
}, 220 + delayMs));
});
timers.push(window.setTimeout(() => { svg!.setAttribute('aria-busy', 'false'); }, 220 + 900));
}
function onClick() { human(); }
function onKey(e: KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); } }
reload.addEventListener('click', onClick);
reload.addEventListener('keydown', onKey);
return () => {
reload.removeEventListener('click', onClick);
reload.removeEventListener('keydown', onKey);
timers.forEach((id) => window.clearTimeout(id));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cd + ' ' + styles['is-demo']} ref={rootRef}>
<svg className={styles.cdSvg} ref={svgRef} viewBox="0 0 120 120" role="img" aria-label="완료율 도넛 그래프" aria-busy="true">
<circle className={styles.cdTrack} cx={60} cy={60} r={45} />
<circle className={styles.cdSpin} cx={60} cy={60} r={45} />
{VALUES.map((_v, i) => (
<circle
key={i}
id={'cdSeg' + i}
className={styles.cdSeg}
cx={60}
cy={60}
r={45}
data-i={i}
ref={(el: SVGCircleElement | null) => { if (el) segRefs.current![i] = el; }}
/>
))}
</svg>
<button type="button" className={styles.cdReload} ref={reloadRef}>완료율 다시 집계</button>
</div>
</main>
);
}03 대기 링이 실제 구간으로 바뀌는 도넛 차트 로딩 — 측정 누적면적 6.744% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 로딩 스켈레톤 ui 9종 — 차트가 계산으로 완성되는 순간까지
이 데모가 속한 글
로딩 스켈레톤 ui 9종 — 차트가 계산으로 완성되는 순간까지로딩 스켈레톤 ui 아홉 가지 가운데 하나는 막대마다 도착 지연을 열 번호로 계산해 순서대로 실제 높이로 펴지고, 하나는 히트맵 칸이 대각선 거리로 계산된 지연만큼 차례로 색을 채우며, 하나는 화면이 동작 줄이기를 이 글의 데모 전부 보기

