05行の順序から遅延を計算して流れるスケルトンローディング
コメント読み込み中に各行の順序を計算して輝きが上から下へ時差をつけて流れ、実際のコメントが届いた行から即座にその場で切り替わる。
使いどころコメント一覧の初回読み込み
SHIMMER_STAGGER_STEP_MSskel__glarerowsInOrder

<main class="stage">
<div class="skel is-demo" id="skelRoot">
<div class="skel__head">
<button type="button" class="skel__replayBtn" id="skelReplay">再読み込み</button>
</div>
<ul class="skel__list" role="list" id="skelList">
<li class="skel__item" data-row="0">
<div class="skel__ghost">
<span class="skel__bone skel__avatar"><i class="skel__glare"></i></span>
<span class="skel__boneGroup">
<span class="skel__bone skel__bone--name"><i class="skel__glare"></i></span>
<span class="skel__bone skel__bone--text"><i class="skel__glare"></i></span>
</span>
</div>
<div class="skel__real">
<span class="skel__realAvatar" aria-hidden="true"></span>
<div class="skel__realBody">
<p class="skel__meta"><strong class="skel__name">天の川の夜</strong><span class="skel__time">7分前</span></p>
<p class="skel__text">skeleton が消えるタイミングが滑らかです。</p>
</div>
</div>
</li>
<li class="skel__item" data-row="1">
<div class="skel__ghost">
<span class="skel__bone skel__avatar"><i class="skel__glare"></i></span>
<span class="skel__boneGroup">
<span class="skel__bone skel__bone--name"><i class="skel__glare"></i></span>
<span class="skel__bone skel__bone--text"><i class="skel__glare"></i></span>
</span>
</div>
<div class="skel__real">
<span class="skel__realAvatar" aria-hidden="true"></span>
<div class="skel__realBody">
<p class="skel__meta"><strong class="skel__name">雲のかけら</strong><span class="skel__time">31分前</span></p>
<p class="skel__text">行ごとにキラめきが1拍ずつずれているのが分かりますね。</p>
</div>
</div>
</li>
<li class="skel__item" data-row="2">
<div class="skel__ghost">
<span class="skel__bone skel__avatar"><i class="skel__glare"></i></span>
<span class="skel__boneGroup">
<span class="skel__bone skel__bone--name"><i class="skel__glare"></i></span>
<span class="skel__bone skel__bone--text"><i class="skel__glare"></i></span>
</span>
</div>
<div class="skel__real">
<span class="skel__realAvatar" aria-hidden="true"></span>
<div class="skel__realBody">
<p class="skel__meta"><strong class="skel__name">月光書店</strong><span class="skel__time">1時間前</span></p>
<p class="skel__text">ロード状態をこう表現すると待ち時間が退屈しにくいです。</p>
</div>
</div>
</li>
<li class="skel__item" data-row="3">
<div class="skel__ghost">
<span class="skel__bone skel__avatar"><i class="skel__glare"></i></span>
<span class="skel__boneGroup">
<span class="skel__bone skel__bone--name"><i class="skel__glare"></i></span>
<span class="skel__bone skel__bone--text"><i class="skel__glare"></i></span>
</span>
</div>
<div class="skel__real">
<span class="skel__realAvatar" aria-hidden="true"></span>
<div class="skel__realBody">
<p class="skel__meta"><strong class="skel__name">にわか雨の傘</strong><span class="skel__time">2時間前</span></p>
<p class="skel__text">次の記事でもこのパターンを使おうと思います。</p>
</div>
</div>
</li>
</ul>
<p class="skel__sr" id="skelStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.skel {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $tick-panel;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
box-sizing: border-box;
padding: $sp-4;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.skel__head { display: flex; justify-content: flex-end; }
.skel__replayBtn {
display: inline-flex;
align-items: center;
gap: $sp-1;
border: 0;
background: rgba(255, 255, 255, .07);
color: $subject-blue-light;
font-size: 11.5px;
font-weight: 600;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.skel__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
flex: 1;
}
.skel__item {
position: relative;
flex: 1;
min-height: 0;
border-radius: $r-sm;
}
.skel__ghost, .skel__real {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
}
.skel__real { opacity: 0; }
.skel__item.is-real .skel__ghost { visibility: hidden; }
.skel__bone {
position: relative;
display: block;
overflow: hidden;
background: rgba(255, 255, 255, .08);
border-radius: $r-xs;
}
.skel__avatar { flex: none; width: 24px; height: 24px; border-radius: 50%; }
.skel__boneGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: $sp-1;
}
.skel__bone--name { height: 9px; width: 38%; border-radius: $r-pill; }
.skel__bone--text { height: 9px; width: 82%; border-radius: $r-pill; }
.skel__glare {
position: absolute;
top: 0;
left: 0;
width: 45%;
height: 100%;
background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, .22) 50%, transparent 100%);
will-change: transform;
}
.skel__realAvatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(160deg, $subject-lilac, $subject-blue-light);
}
.skel__realBody { min-width: 0; flex: 1; }
.skel__meta { margin: 0 0 4px; display: flex; align-items: baseline; gap: $sp-2; }
.skel__name { color: #fff; font-size: 12px; font-weight: 700; }
.skel__time { color: rgba(255, 255, 255, .5); font-size: 10.5px; }
.skel__text { margin: 0; color: rgba(255, 255, 255, .86); font-size: 11.5px; line-height: 1.45; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.skel__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.skel { padding: $sp-3; }
.skel__text, .skel__name { font-size: 10.5px; }
}
@media (prefers-reduced-motion: reduce) {
.skel__glare { display: none; }
}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);
}
.skel {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #231f28;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
box-sizing: border-box;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.skel__head {
display: flex;
justify-content: flex-end;
}
.skel__replayBtn {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: rgba(255, 255, 255, 0.07);
color: #5b8cf8;
font-size: 11.5px;
font-weight: 600;
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
}
.skel__replayBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.skel__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.skel__item {
position: relative;
flex: 1;
min-height: 0;
border-radius: 8px;
}
.skel__ghost, .skel__real {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
}
.skel__real {
opacity: 0;
}
.skel__item.is-real .skel__ghost {
visibility: hidden;
}
.skel__bone {
position: relative;
display: block;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.skel__avatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
}
.skel__boneGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.skel__bone--name {
height: 9px;
width: 38%;
border-radius: 999px;
}
.skel__bone--text {
height: 9px;
width: 82%;
border-radius: 999px;
}
.skel__glare {
position: absolute;
top: 0;
left: 0;
width: 45%;
height: 100%;
background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.22) 50%, transparent 100%);
will-change: transform;
}
.skel__realAvatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(160deg, #b9a0ff, #5b8cf8);
}
.skel__realBody {
min-width: 0;
flex: 1;
}
.skel__meta {
margin: 0 0 2px;
display: flex;
align-items: baseline;
gap: 8px;
}
.skel__name {
color: #fff;
font-size: 12px;
font-weight: 700;
}
.skel__time {
color: rgba(255, 255, 255, 0.5);
font-size: 10.5px;
}
.skel__text {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 11.5px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skel__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.skel {
padding: 12px;
}
.skel__text, .skel__name {
font-size: 10.5px;
}
}
@media (prefers-reduced-motion: reduce) {
.skel__glare {
display: none;
}
}(function () {
// 기법: 행 순서(위→아래 data-row 정렬)를 계산해 각 행 반짝임(glare) 시작을
// SHIMMER_STAGGER_STEP_MS 씩 밀어 시차를 만들고, "실제 댓글 도착"도 같은 순서로 stagger
// 시켜 도착한 행은 그 즉시 스켈레톤과 제자리 교차 페이드한다.
// glare 는 뼈대 안 절대위치 하이라이트 띠를 translateX 로 무한 스윕한다.
var SHIMMER_BASE_DELAY_MS = 200;
var SHIMMER_STAGGER_STEP_MS = 140;
var SWAP_BASE_DELAY_MS = 760;
var SWAP_STAGGER_STEP_MS = 170;
var root = document.getElementById('skelRoot');
var list = document.getElementById('skelList');
var replay = document.getElementById('skelReplay');
var status = document.getElementById('skelStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function rowsInOrder() {
var items = list.querySelectorAll('.skel__item');
var rows = [];
for (var i = 0; i < items.length; i += 1) rows.push(items[i]);
rows.sort(function (a, b) {
return parseInt(a.getAttribute('data-row'), 10) - parseInt(b.getAttribute('data-row'), 10);
});
return rows;
}
function resetRow(item) {
item.getAnimations().forEach(function (a) { a.cancel(); });
item.querySelectorAll('.skel__glare, .skel__ghost, .skel__real').forEach(function (b) {
b.getAnimations().forEach(function (a) { a.cancel(); });
});
item.classList.remove('is-real');
}
function swapRow(item, delayMs) {
var ghost = item.querySelector('.skel__ghost');
var real = item.querySelector('.skel__real');
ghost.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 170, delay: delayMs, easing: 'ease-out', fill: 'forwards' });
var anim = real.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 170, delay: delayMs, easing: 'ease-out', fill: 'forwards' });
anim.onfinish = function () { item.classList.add('is-real'); };
}
function runSequence(animate) {
var rows = rowsInOrder();
for (var i = 0; i < rows.length; i += 1) {
resetRow(rows[i]);
if (!animate || reduce) {
rows[i].classList.add('is-real');
continue;
}
// glare 무한 스윕 — 행 순서로 stagger.
var glareDelay = SHIMMER_BASE_DELAY_MS + i * SHIMMER_STAGGER_STEP_MS;
rows[i].querySelectorAll('.skel__glare').forEach(function (glare) {
glare.animate(
[{ transform: 'translateX(-220%)' }, { transform: 'translateX(340%)' }],
{ duration: 1300, delay: glareDelay, iterations: Infinity, easing: 'linear' }
);
});
swapRow(rows[i], SWAP_BASE_DELAY_MS + i * SWAP_STAGGER_STEP_MS);
}
status.textContent = animate ? 'コメント4件を読み込みました。' : 'コメント4件が表示されました。';
}
replay.addEventListener('click', function () {
root.classList.remove('is-demo');
runSequence(true);
});
replay.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); replay.click(); }
});
if (reduce) {
runSequence(false);
} else {
runSequence(true);
}
})();import { useEffect, useRef } from "react";
import styles from "./CommentThreadSkeletonShimmerStagger.module.scss";
// 기법: 행 순서(위→아래 data-row 정렬)를 계산해 각 행 반짝임(glare) 시작을
// SHIMMER_STAGGER_STEP_MS 씩 밀어 시차를 만들고, "실제 댓글 도착"도 같은 순서로 stagger
// 시켜 도착한 행은 그 즉시 스켈레톤과 제자리 교차 페이드한다.
const SHIMMER_BASE_DELAY_MS = 200;
const SHIMMER_STAGGER_STEP_MS = 140;
const SWAP_BASE_DELAY_MS = 760;
const SWAP_STAGGER_STEP_MS = 170;
const ROWS = [
{ name: "은하수밤", time: "7분 전", text: "스켈레톤이 사라지는 타이밍이 매끄러워요." },
{ name: "구름조각", time: "31분 전", text: "행마다 반짝임이 한 박자씩 밀리는 게 보이네요." },
{ name: "달빛서점", time: "1시간 전", text: "로딩 상태를 이렇게 표현하면 기다림이 덜 지루해요." },
{ name: "소나기우산", time: "2시간 전", text: "다음 글에서도 이 패턴 써먹어야겠어요." },
];
export default function CommentThreadSkeletonShimmerStagger() {
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const replayRef = useRef<HTMLButtonElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const list = listRef.current;
const replay = replayRef.current;
const status = statusRef.current;
if (!root || !list || !replay || !status) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function rowsInOrder(): HTMLElement[] {
const items = Array.from(list!.querySelectorAll<HTMLElement>(".skel__item"));
items.sort((a, b) => parseInt(a.getAttribute("data-row")!, 10) - parseInt(b.getAttribute("data-row")!, 10));
return items;
}
function resetRow(item: HTMLElement) {
item.getAnimations().forEach((a) => a.cancel());
item.querySelectorAll<HTMLElement>(".skel__glare, .skel__ghost, .skel__real").forEach((b) => {
b.getAnimations().forEach((a) => a.cancel());
});
item.classList.remove("is-real");
}
function swapRow(item: HTMLElement, delayMs: number) {
const ghost = item.querySelector<HTMLElement>(".skel__ghost");
const real = item.querySelector<HTMLElement>(".skel__real");
if (!ghost || !real) return;
ghost.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 170, delay: delayMs, easing: "ease-out", fill: "forwards" });
const anim = real.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 170, delay: delayMs, easing: "ease-out", fill: "forwards" });
anim.onfinish = () => { item.classList.add("is-real"); };
}
function runSequence(animate: boolean) {
const rows = rowsInOrder();
rows.forEach((row, i) => {
resetRow(row);
if (!animate || reduce) {
row.classList.add("is-real");
return;
}
const glareDelay = SHIMMER_BASE_DELAY_MS + i * SHIMMER_STAGGER_STEP_MS;
row.querySelectorAll<HTMLElement>(".skel__glare").forEach((glare) => {
glare.animate(
[{ transform: "translateX(-220%)" }, { transform: "translateX(340%)" }],
{ duration: 1300, delay: glareDelay, iterations: Infinity, easing: "linear" }
);
});
swapRow(row, SWAP_BASE_DELAY_MS + i * SWAP_STAGGER_STEP_MS);
});
status!.textContent = animate ? "댓글 4개를 불러왔습니다." : "댓글 4개가 표시되었습니다.";
}
const onActivate = () => { root.classList.remove("is-demo"); runSequence(true); };
replay.addEventListener("click", onActivate);
replay.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onActivate(); }
});
runSequence(!reduce);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.skel} is-demo`} ref={rootRef}>
<div className={styles.skel__head}>
<button type="button" className={styles.skel__replayBtn} ref={replayRef}>다시 불러오기</button>
</div>
<ul className={styles.skel__list} role="list" ref={listRef}>
{ROWS.map((r, i) => (
<li key={i} className={`skel__item ${styles.skel__item}`} data-row={i}>
<div className={`skel__ghost ${styles.skel__ghost}`}>
<span className={`skel__avatar ${styles.skel__avatar}`}><i className={`skel__glare ${styles.skel__glare}`} /></span>
<span className={styles.skel__boneGroup}>
<span className={`skel__bone ${styles["skel__bone--name"]}`}><i className={`skel__glare ${styles.skel__glare}`} /></span>
<span className={`skel__bone ${styles["skel__bone--text"]}`}><i className={`skel__glare ${styles.skel__glare}`} /></span>
</span>
</div>
<div className={`skel__real ${styles.skel__real}`}>
<span className={styles.skel__realAvatar} aria-hidden="true" />
<div className={styles.skel__realBody}>
<p className={styles.skel__meta}>
<strong className={styles.skel__name}>{r.name}</strong>
<span className={styles.skel__time}>{r.time}</span>
</p>
<p className={styles.skel__text}>{r.text}</p>
</div>
</div>
</li>
))}
</ul>
<p className={styles.skel__sr} ref={statusRef} aria-live="polite" />
</div>
</main>
);
}05 行の順序から遅延を計算して流れるスケルトンローディング — 計測 累積面積 12.333% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · コメント欄 css 実装9パターン — 深さから削除後の詰めまで計算するリスト
このデモが属する記事
コメント欄 css 実装9パターン — 深さから削除後の詰めまで計算するリストコメント欄 css は、返信の深さ・行の高さ・経過時間を計算して動かすコメントリストの実装です。消えたコメントが占めていたピクセル値がそのまま下のコメントが上がる距離になり、返信のインデントは深さの段数から、固定コメントの光は固定されてから この記事のデモをすべて見る


