07칸이 대각선으로 채워지는 히트맵 로딩
회색 칸이 격자로 먼저 놓이고, 값이 오면 각 칸의 행·열 합으로 대각 거리를 계산해 그 거리만큼 지연을 두고 실제 색 농도로 바뀐다.
쓰임달력형 히트맵이나 상관관계 표가 값을 받기 전 자리 잡는 자리
animationDelaygetAnimationstransform-origin

<main class="stage">
<div class="ch is-demo" id="ch" role="img" aria-label="요일별 활동량 히트맵" aria-busy="true"></div>
<button type="button" class="ch__reload" id="chReload">히트맵 다시 불러오기</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $chart-4;
@include stage($stage-paper);
.ch {
width: min(190px, 66vw);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
}
.ch__cell { position: relative; aspect-ratio: 1; border-radius: $r-xs; overflow: hidden; }
.ch__sk {
position: absolute; inset: 0; border-radius: inherit;
background: rgba($subject-ink, .1);
animation: chSkeleton $duration $easing infinite;
}
.ch__real {
position: absolute; inset: 0; border-radius: inherit;
opacity: 0; transform: scale(.9); transform-origin: center;
animation: chReveal $duration $easing infinite;
}
.ch__real--b0 { background: $chart-1; }
.ch__real--b1 { background: $chart-2; }
.ch__real--b2 { background: $chart-3; }
.ch__real--b3 { background: $chart-4; }
.ch__reload {
display: block; margin: $sp-3 auto 0; appearance: none; border: none; cursor: pointer;
background: $subject-ink; color: $subject-cream; font-weight: 700; font-size: 12px;
padding: $sp-1 $sp-3; border-radius: $r-pill;
}
.ch__reload:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
@keyframes chSkeleton {
0% { opacity: 1; }
10% { opacity: 1; }
22% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes chReveal {
0% { opacity: 0; transform: scale(.9); }
10% { opacity: 0; transform: scale(.9); }
26% { opacity: 1; transform: scale(1); }
92% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(.9); }
}
@media (max-width: 340px) {
.ch { width: min(128px, 56vw); gap: $sp-1; }
.ch__reload { margin-top: $sp-1; font-size: 10px; padding: 4px $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: 자동 루프를 멈추고 완성된 히트맵 칸만 보인다
.ch__sk { animation: none; opacity: 0; }
.ch__real { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ch {
width: min(190px, 66vw);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.ch__cell {
position: relative;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
}
.ch__sk {
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(23, 20, 26, 0.1);
animation: chSkeleton 2s ease-out infinite;
}
.ch__real {
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
transform: scale(0.9);
transform-origin: center;
animation: chReveal 2s ease-out infinite;
}
.ch__real--b0 {
background: #2f6df6;
}
.ch__real--b1 {
background: #c2361a;
}
.ch__real--b2 {
background: #2b9d74;
}
.ch__real--b3 {
background: #8a5cd6;
}
.ch__reload {
display: block;
margin: 12px auto 0;
appearance: none;
border: none;
cursor: pointer;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
}
.ch__reload:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes chSkeleton {
0% {
opacity: 1;
}
10% {
opacity: 1;
}
22% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes chReveal {
0% {
opacity: 0;
transform: scale(0.9);
}
10% {
opacity: 0;
transform: scale(0.9);
}
26% {
opacity: 1;
transform: scale(1);
}
92% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.9);
}
}
@media (max-width: 340px) {
.ch {
width: min(128px, 56vw);
gap: 4px;
}
.ch__reload {
margin-top: 4px;
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ch__sk {
animation: none;
opacity: 0;
}
.ch__real {
animation: none;
opacity: 1;
transform: scale(1);
}
}(function () {
var COLS = 4;
var ROWS = 4;
var WAVE_SPEED = 6; // 칸 단위 / 초 — 값이 클수록 파동이 격자를 빨리 훑는다
var VALUES = [
12, 38, 61, 90,
27, 55, 82, 34,
70, 20, 46, 65,
88, 63, 18, 41
];
var grid = document.getElementById('ch');
var reload = document.getElementById('chReload');
var cells = [];
function bucket(v) { return Math.min(3, Math.floor(v / 25)); }
// 계산 겹: 오른쪽 위 모서리를 파원으로 두고 유클리드 거리를 파동 속도로 나눠 지연을 구한다
function waveDelayMs(row, col) {
var dx = (COLS - 1 - col);
var dy = row;
var distance = Math.sqrt(dx * dx + dy * dy);
return Math.round((distance / WAVE_SPEED) * 1000);
}
for (var r = 0; r < ROWS; r += 1) {
for (var c = 0; c < COLS; c += 1) {
var idx = r * COLS + c;
var cell = document.createElement('div');
cell.className = 'ch__cell';
cell.setAttribute('data-row', String(r));
cell.setAttribute('data-col', String(c));
var sk = document.createElement('div');
sk.className = 'ch__sk';
var real = document.createElement('div');
real.className = 'ch__real ch__real--b' + bucket(VALUES[idx]);
cell.appendChild(sk);
cell.appendChild(real);
grid.appendChild(cell);
cells.push(cell);
var delay = waveDelayMs(r, c);
sk.style.animationDelay = delay + 'ms';
real.style.animationDelay = delay + 'ms';
}
}
function human() {
grid.classList.remove('is-demo');
grid.setAttribute('aria-busy', 'true');
var maxDelay = 0;
cells.forEach(function (cell) {
var sk = cell.querySelector('.ch__sk');
var real = cell.querySelector('.ch__real');
sk.getAnimations().forEach(function (a) { a.cancel(); });
real.getAnimations().forEach(function (a) { a.cancel(); });
sk.style.animation = 'none';
real.style.animation = 'none';
sk.style.opacity = '1';
real.style.opacity = '0';
real.style.transform = 'scale(.9)';
var row = Number(cell.getAttribute('data-row'));
var col = Number(cell.getAttribute('data-col'));
var delay = waveDelayMs(row, col);
if (delay > maxDelay) { maxDelay = delay; }
window.setTimeout(function () {
sk.style.transition = 'opacity 200ms ease-out';
real.style.transition = 'opacity 200ms ease-out, transform 200ms ease-out';
sk.style.opacity = '0';
real.style.opacity = '1';
real.style.transform = 'scale(1)';
}, 200 + delay);
});
window.setTimeout(function () { grid.setAttribute('aria-busy', 'false'); }, 200 + maxDelay + 260);
}
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 './ChartLoadingHeatmapDiagonalReveal.module.scss';
const COLS = 4;
const ROWS = 4;
const WAVE_SPEED = 6; // 칸 단위 / 초 — 값이 클수록 파동이 격자를 빨리 훑는다
const VALUES = [
12, 38, 61, 90,
27, 55, 82, 34,
70, 20, 46, 65,
88, 63, 18, 41
];
const BUCKET_CLASS = [styles.chRealB0, styles.chRealB1, styles.chRealB2, styles.chRealB3];
function bucket(v: number) { return Math.min(3, Math.floor(v / 25)); }
export default function ChartLoadingHeatmapDiagonalReveal() {
const gridRef = useRef<HTMLDivElement>(null);
const reloadRef = useRef<HTMLButtonElement>(null);
const skRefs = useRef<HTMLDivElement[]>([]);
const realRefs = useRef<HTMLDivElement[]>([]);
const cells: { row: number; col: number; v: number }[] = [];
for (let r = 0; r < ROWS; r += 1) {
for (let c = 0; c < COLS; c += 1) { cells.push({ row: r, col: c, v: VALUES[r * COLS + c] }); }
}
useEffect(() => {
const grid = gridRef.current;
const reload = reloadRef.current;
if (!grid || !reload) return undefined;
const sks = skRefs.current!;
const reals = realRefs.current!;
if (!sks || !reals) return undefined;
// 계산 겹: 오른쪽 위 모서리를 파원으로 두고 유클리드 거리를 파동 속도로 나눠 지연을 구한다
function waveDelayMs(row: number, col: number) {
const dx = COLS - 1 - col;
const dy = row;
const distance = Math.sqrt(dx * dx + dy * dy);
return Math.round((distance / WAVE_SPEED) * 1000);
}
cells.forEach((cell, i) => {
const delay = waveDelayMs(cell.row, cell.col);
sks[i].style.animationDelay = delay + 'ms';
reals[i].style.animationDelay = delay + 'ms';
});
const timers: number[] = [];
function human() {
grid!.classList.remove(styles['is-demo']);
grid!.setAttribute('aria-busy', 'true');
let maxDelay = 0;
cells.forEach((cell, i) => {
const sk = sks[i];
const real = reals[i];
sk.getAnimations().forEach((a) => a.cancel());
real.getAnimations().forEach((a) => a.cancel());
sk.style.animation = 'none';
real.style.animation = 'none';
sk.style.opacity = '1';
real.style.opacity = '0';
real.style.transform = 'scale(.9)';
const delay = waveDelayMs(cell.row, cell.col);
if (delay > maxDelay) { maxDelay = delay; }
timers.push(window.setTimeout(() => {
sk.style.transition = 'opacity 200ms ease-out';
real.style.transition = 'opacity 200ms ease-out, transform 200ms ease-out';
sk.style.opacity = '0';
real.style.opacity = '1';
real.style.transform = 'scale(1)';
}, 200 + delay));
});
timers.push(window.setTimeout(() => { grid!.setAttribute('aria-busy', 'false'); }, 200 + maxDelay + 260));
}
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.ch + ' ' + styles['is-demo']} ref={gridRef} role="img" aria-label="요일별 활동량 히트맵" aria-busy="true">
{cells.map((cell, i) => (
<div className={styles.chCell} data-row={cell.row} data-col={cell.col} key={i}>
<div className={styles.chSk} ref={(el: HTMLDivElement | null) => { if (el) skRefs.current![i] = el; }} />
<div
className={styles.chReal + ' ' + BUCKET_CLASS[bucket(cell.v)]}
ref={(el: HTMLDivElement | null) => { if (el) realRefs.current![i] = el; }}
/>
</div>
))}
</div>
<button type="button" className={styles.chReload} ref={reloadRef}>히트맵 다시 불러오기</button>
</main>
);
}07 칸이 대각선으로 채워지는 히트맵 로딩 — 측정 누적면적 21.908% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 로딩 스켈레톤 ui 9종 — 차트가 계산으로 완성되는 순간까지
이 데모가 속한 글
로딩 스켈레톤 ui 9종 — 차트가 계산으로 완성되는 순간까지로딩 스켈레톤 ui 아홉 가지 가운데 하나는 막대마다 도착 지연을 열 번호로 계산해 순서대로 실제 높이로 펴지고, 하나는 히트맵 칸이 대각선 거리로 계산된 지연만큼 차례로 색을 채우며, 하나는 화면이 동작 줄이기를 이 글의 데모 전부 보기


