05Skeleton Loading Whose Shimmer Delay Is Calculated From Row Order
While comments load, each row's order is calculated to stagger the shimmer sweep from top to bottom, and any row whose real comment arrives swaps in place immediately.
Best forFirst load of a comment list
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">Reload</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">GalaxyNight</strong><span class="skel__time">7 min ago</span></p>
<p class="skel__text">The timing of the skeleton fading is smooth.</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">CloudBits</strong><span class="skel__time">31 min ago</span></p>
<p class="skel__text">You can see each row's shimmer lag one beat apart.</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">MoonBookshop</strong><span class="skel__time">1 hour ago</span></p>
<p class="skel__text">Showing the loading state this way makes waiting less boring.</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">ShowerUmbrella</strong><span class="skel__time">2 hours ago</span></p>
<p class="skel__text">I should use this pattern in my next post too.</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 ? 'Loaded 4 comments.' : '4 comments shown.';
}
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 Skeleton Loading Whose Shimmer Delay Is Calculated From Row Order — Measured area 12.333% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Comment Section UI: 9 Thread List Patterns
The post this demo lives in
Comment Section UI: 9 Thread List PatternsThe pixels a deleted comment used to fill become exactly how far the rows below rise, and reply indentation pulls its wi See all demos in this post
If you like this effect - similar demos
Lists & tables07 Skeleton shimmer rows9 Swipe to Delete List UI Patterns (Copy-Paste)
Lists & tables09 Chain-burst check marker — stacking a delay value so checks pop in cascadeCSS List Style: 9 Marker Design Patterns
Lists & tables04 Row Whose Shadow Lifts Higher With RankLeaderboard UI Design: 9 Rank-Change List Patterns