06계열마다 감쇠로 늦게 자리잡는 멀티시리즈 로딩
여러 계열 스켈레톤이 동시에 놓였다가, 값이 오면 계열 번호에 감쇠 계수를 곱한 지연으로 하나씩 실제 값에 자리잡아 전부가 한꺼번에 바뀌지 않는다.
쓰임여러 지표를 겹쳐 보여주는 비교 차트가 값을 받는 자리
--lengetTotalLengthanimationDelay

<main class="stage">
<div class="cm is-demo" id="cm">
<svg class="cm__svg" id="cmSvg" viewBox="0 0 200 100" role="img" aria-label="계열 3종 추이 그래프" aria-busy="true">
<line class="cm__axis" x1="4" y1="92" x2="196" y2="92" />
<path class="cm__flat" d="M4,60 L196,60" />
<path class="cm__series" id="cmSeries0" data-i="0" d="M4,70 L44,52 L84,64 L124,34 L164,46 L196,26" />
<path class="cm__series" id="cmSeries1" data-i="1" d="M4,50 L44,66 L84,40 L124,58 L164,30 L196,44" />
<path class="cm__series" id="cmSeries2" data-i="2" d="M4,30 L44,38 L84,20 L124,42 L164,18 L196,32" />
</svg>
<button type="button" class="cm__reload" id="cmReload">그래프 다시 불러오기</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $chart-1;
@include stage($stage-ink);
.cm { width: min(300px, 88vw); }
.cm__svg { width: 100%; height: auto; overflow: visible; }
.cm__axis { stroke: rgba($subject-cream, .22); stroke-width: 1; }
.cm__flat {
fill: none; stroke: rgba($subject-cream, .3); stroke-width: 2; stroke-dasharray: 5 5;
stroke-linecap: round; animation: cmFlatFade $duration $easing infinite;
}
.cm__series { fill: none; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; animation: cmDraw $duration $easing infinite; }
#cmSeries0 { stroke: $chart-1; }
#cmSeries1 { stroke: $chart-2; }
#cmSeries2 { stroke: $chart-3; }
.cm__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;
}
.cm__reload:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
@keyframes cmFlatFade {
0% { opacity: 1; }
8% { opacity: 1; }
16% { opacity: 0; }
84% { opacity: 0; }
94% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cmDraw {
0% { stroke-dashoffset: var(--len); }
10% { stroke-dashoffset: var(--len); }
40% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: var(--len); }
}
@media (max-width: 340px) {
.cm { width: min(190px, 80vw); }
.cm__reload { margin-top: $sp-2; font-size: 11px; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 자동 루프를 멈추고 완성된 계열만 보인다
.cm__flat { animation: none; opacity: 0; }
.cm__series { animation: none; stroke-dashoffset: 0; }
}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);
}
.cm {
width: min(300px, 88vw);
}
.cm__svg {
width: 100%;
height: auto;
overflow: visible;
}
.cm__axis {
stroke: rgba(255, 247, 230, 0.22);
stroke-width: 1;
}
.cm__flat {
fill: none;
stroke: rgba(255, 247, 230, 0.3);
stroke-width: 2;
stroke-dasharray: 5 5;
stroke-linecap: round;
animation: cmFlatFade 2s ease-out infinite;
}
.cm__series {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
animation: cmDraw 2s ease-out infinite;
}
#cmSeries0 {
stroke: #2f6df6;
}
#cmSeries1 {
stroke: #c2361a;
}
#cmSeries2 {
stroke: #2b9d74;
}
.cm__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;
}
.cm__reload:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@keyframes cmFlatFade {
0% {
opacity: 1;
}
8% {
opacity: 1;
}
16% {
opacity: 0;
}
84% {
opacity: 0;
}
94% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cmDraw {
0% {
stroke-dashoffset: var(--len);
}
10% {
stroke-dashoffset: var(--len);
}
40% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: var(--len);
}
}
@media (max-width: 340px) {
.cm {
width: min(190px, 80vw);
}
.cm__reload {
margin-top: 8px;
font-size: 11px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__flat {
animation: none;
opacity: 0;
}
.cm__series {
animation: none;
stroke-dashoffset: 0;
}
}(function () {
var STEP_MS = 220;
var DAMPING = 0.55;
var root = document.getElementById('cm');
var reload = document.getElementById('cmReload');
var series = [0, 1, 2].map(function (i) { return document.getElementById('cmSeries' + i); });
// 계산 겹: 계열 지연을 감쇠(등비) 누적으로 계산한다 — 뒤 계열일수록 늘어나는 간격이 줄어들며 안정된다
function seriesDelay(i) {
var total = 0;
for (var k = 1; k <= i; k += 1) { total += STEP_MS * Math.pow(DAMPING, k - 1); }
return Math.round(total);
}
series.forEach(function (path, i) {
var len = Math.round(path.getTotalLength());
path.style.setProperty('--len', String(len));
path.style.strokeDasharray = String(len);
path.style.strokeDashoffset = String(len);
path.style.animationDelay = seriesDelay(i) + 'ms';
});
function human() {
root.classList.remove('is-demo');
root.querySelector('#cmSvg').setAttribute('aria-busy', 'true');
var lastDelay = 0;
series.forEach(function (path, i) {
var len = parseInt(path.style.getPropertyValue('--len'), 10);
path.getAnimations().forEach(function (a) { a.cancel(); });
path.style.animation = 'none';
path.style.strokeDashoffset = String(len);
var delayMs = seriesDelay(i);
lastDelay = delayMs;
window.setTimeout(function () {
path.animate(
[{ strokeDashoffset: len }, { strokeDashoffset: 0 }],
{ duration: 420, easing: 'ease-out', fill: 'forwards' }
);
}, 200 + delayMs);
});
window.setTimeout(function () { root.querySelector('#cmSvg').setAttribute('aria-busy', 'false'); }, 200 + lastDelay + 500);
}
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 './ChartLoadingMultiSeriesCascade.module.scss';
const STEP_MS = 220;
const DAMPING = 0.55;
const PATHS = [
'M4,70 L44,52 L84,64 L124,34 L164,46 L196,26',
'M4,50 L44,66 L84,40 L124,58 L164,30 L196,44',
'M4,30 L44,38 L84,20 L124,42 L164,18 L196,32'
];
export default function ChartLoadingMultiSeriesCascade() {
const rootRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const reloadRef = useRef<HTMLButtonElement>(null);
const seriesRefs = useRef<SVGPathElement[]>([]);
useEffect(() => {
const root = rootRef.current;
const svg = svgRef.current;
const reload = reloadRef.current;
if (!root || !svg || !reload) return undefined;
const series = seriesRefs.current!;
if (!series || series.length !== PATHS.length) return undefined;
// 계산 겹: 계열 지연을 감쇠(등비) 누적으로 계산한다 — 뒤 계열일수록 늘어나는 간격이 줄어들며 안정된다
function seriesDelay(i: number) {
let total = 0;
for (let k = 1; k <= i; k += 1) { total += STEP_MS * Math.pow(DAMPING, k - 1); }
return Math.round(total);
}
series.forEach((path, i) => {
const len = Math.round(path.getTotalLength());
path.style.setProperty('--len', String(len));
path.style.strokeDasharray = String(len);
path.style.strokeDashoffset = String(len);
path.style.animationDelay = seriesDelay(i) + 'ms';
});
const timers: number[] = [];
function human() {
root!.classList.remove(styles['is-demo']);
svg!.setAttribute('aria-busy', 'true');
let lastDelay = 0;
series.forEach((path, i) => {
const len = parseInt(path.style.getPropertyValue('--len'), 10);
path.getAnimations().forEach((a) => a.cancel());
path.style.animation = 'none';
path.style.strokeDashoffset = String(len);
const delayMs = seriesDelay(i);
lastDelay = delayMs;
timers.push(window.setTimeout(() => {
path.animate(
[{ strokeDashoffset: len }, { strokeDashoffset: 0 }],
{ duration: 420, easing: 'ease-out', fill: 'forwards' }
);
}, 200 + delayMs));
});
timers.push(window.setTimeout(() => { svg!.setAttribute('aria-busy', 'false'); }, 200 + lastDelay + 500));
}
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.cm + ' ' + styles['is-demo']} ref={rootRef}>
<svg className={styles.cmSvg} ref={svgRef} viewBox="0 0 200 100" role="img" aria-label="계열 3종 추이 그래프" aria-busy="true">
<line className={styles.cmAxis} x1={4} y1={92} x2={196} y2={92} />
<path className={styles.cmFlat} d="M4,60 L196,60" />
{PATHS.map((d, i) => (
<path
key={i}
id={'cmSeries' + i}
className={styles.cmSeries}
data-i={i}
d={d}
ref={(el: SVGPathElement | null) => { if (el) seriesRefs.current![i] = el; }}
/>
))}
</svg>
<button type="button" className={styles.cmReload} ref={reloadRef}>그래프 다시 불러오기</button>
</div>
</main>
);
}06 계열마다 감쇠로 늦게 자리잡는 멀티시리즈 로딩 — 측정 누적면적 3.492% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 로딩 스켈레톤 ui 9종 — 차트가 계산으로 완성되는 순간까지
이 데모가 속한 글
로딩 스켈레톤 ui 9종 — 차트가 계산으로 완성되는 순간까지로딩 스켈레톤 ui 아홉 가지 가운데 하나는 막대마다 도착 지연을 열 번호로 계산해 순서대로 실제 높이로 펴지고, 하나는 히트맵 칸이 대각선 거리로 계산된 지연만큼 차례로 색을 채우며, 하나는 화면이 동작 줄이기를 이 글의 데모 전부 보기


