02Skeleton Rows for the First List Load
Before any data arrives, the whole screen is filled with gray bar-shaped skeleton rows, then swapped out all at once the moment real data lands (a different demo from 004's individual skeleton components and 349's append-time skeleton during scroll — this one is a single, one-time full-list swap on first entry).
Best forFirst-visit loading placeholder for a list or feed
measureBarWidthssetLoadedanimation-fill-mode

<main class="stage">
<div class="skl is-demo" id="skl">
<ul class="skl__list" id="sklList" aria-busy="true">
<li class="skl__row" data-row="0">
<span class="skl__bar" data-bar></span>
<span class="skl__content" data-content hidden></span>
</li>
<li class="skl__row" data-row="1">
<span class="skl__bar" data-bar></span>
<span class="skl__content" data-content hidden></span>
</li>
<li class="skl__row" data-row="2">
<span class="skl__bar" data-bar></span>
<span class="skl__content" data-content hidden></span>
</li>
<li class="skl__row" data-row="3">
<span class="skl__bar" data-bar></span>
<span class="skl__content" data-content hidden></span>
</li>
<li class="skl__row" data-row="4">
<span class="skl__bar" data-bar></span>
<span class="skl__content" data-content hidden></span>
</li>
</ul>
<button class="skl__reload" id="sklReload" type="button">Reload</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: index.html 이 캔버스 measureText 로 잰 실제 글자 폭 비율을
// --bar-w 로 각 막대에 주입한다 — 막대 폭이 내용 길이를 미리 반영한다.
@include stage($stage-ink);
.skl {
width: min(100%, 280px);
display: flex;
flex-direction: column;
gap: $sp-3;
align-items: center;
}
.skl__list {
box-sizing: border-box;
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.skl__row {
box-sizing: border-box;
position: relative;
height: 32px;
padding: 0 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .06);
display: flex;
align-items: center;
}
.skl__bar {
--bar-w: 60%;
position: relative;
overflow: hidden;
display: block;
width: var(--bar-w);
height: 10px;
border-radius: $r-xs;
background: rgba(255, 255, 255, .14);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($color, .5), transparent);
transform: translateX(-140%);
animation-name: sklShimmer;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
.skl__content {
color: $stage-paper;
font-weight: 600;
font-size: 12px;
}
.skl__row.is-loaded {
.skl__bar { display: none; }
.skl__content {
animation-name: sklRowIn;
animation-duration: $dur-base;
animation-timing-function: $easing;
animation-fill-mode: backwards;
}
}
@keyframes sklShimmer {
to { transform: translateX(140%); }
}
@keyframes sklRowIn {
from { opacity: 0; transform: translateY($sp-2); }
to { opacity: 1; transform: translateY(0); }
}
.skl__reload {
box-sizing: border-box;
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: rgba(255, 255, 255, .1);
color: $stage-paper;
font-weight: 600;
font-size: 12px;
cursor: pointer;
&:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
}
@media (max-width: 340px) {
.skl { gap: $sp-2; }
.skl__list { gap: $sp-1; }
.skl__row { height: 20px; padding: 0 $sp-1; }
.skl__content { font-size: 11px; }
.skl__reload { padding: $sp-1 $sp-3; }
}
// reduce: 실사용 경로 적용 — 스침·등장 트윈만 멈춘다. 로딩 완료 상태 자체는 그대로 보인다
@media (prefers-reduced-motion: reduce) {
.skl__bar::after { animation: none; opacity: .5; transform: translateX(0); }
.skl__row.is-loaded .skl__content { animation: 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);
}
.skl {
width: min(100%, 280px);
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.skl__list {
box-sizing: border-box;
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.skl__row {
box-sizing: border-box;
position: relative;
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
}
.skl__bar {
--bar-w: 60%;
position: relative;
overflow: hidden;
display: block;
width: var(--bar-w);
height: 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
}
.skl__bar::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(76, 212, 166, 0.5), transparent);
transform: translateX(-140%);
animation-name: sklShimmer;
animation-duration: 220ms;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.skl__content {
color: #ffffff;
font-weight: 600;
font-size: 12px;
}
.skl__row.is-loaded .skl__bar {
display: none;
}
.skl__row.is-loaded .skl__content {
animation-name: sklRowIn;
animation-duration: 300ms;
animation-timing-function: ease-out;
animation-fill-mode: backwards;
}
@keyframes sklShimmer {
to {
transform: translateX(140%);
}
}
@keyframes sklRowIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.skl__reload {
box-sizing: border-box;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
font-weight: 600;
font-size: 12px;
cursor: pointer;
}
.skl__reload:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.skl {
gap: 8px;
}
.skl__list {
gap: 4px;
}
.skl__row {
height: 20px;
padding: 0 4px;
}
.skl__content {
font-size: 11px;
}
.skl__reload {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.skl__bar::after {
animation: none;
opacity: 0.5;
transform: translateX(0);
}
.skl__row.is-loaded .skl__content {
animation: none;
}
}(function () {
var ROWS = [
'H1 growth report summary',
'New signup trend graph',
'Top-selling items this quarter',
'Customer satisfaction survey results',
'Low-stock item alert'
];
var CYCLE_MS = 2000; // 캡처 창(2초)의 배수 — 스켈레톤/로딩완료 상태를 오가는 한 사이클
var LOAD_POINT = 0.45; // 사이클 중 로딩 완료로 전환되는 지점
var RESET_POINT = 0.96; // 이 지점부터 다시 스켈레톤으로
var ENTER_STAGGER_MS = 70; // 행마다 등장 지연 간격
var MIN_BAR_PCT = 32; // 막대 폭 하한(%)
var TICK_MS = 50;
var root = document.getElementById('skl');
var list = document.getElementById('sklList');
var reloadBtn = document.getElementById('sklReload');
var rows = Array.prototype.slice.call(list.querySelectorAll('.skl__row'));
// 계산 겹: 각 행이 로딩 완료 후 실제로 보여줄 글자의 캔버스 실측 폭을 재서
// 스켈레톤 막대 폭을 그 비율로 정한다 — 고정폭이 아니라 내용 길이를 미리 반영한다.
function measureBarWidths() {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var widths = ROWS.map(function (text) { return ctx.measureText(text).width; });
var maxW = Math.max.apply(null, widths);
return widths.map(function (w) {
var pct = (w / maxW) * 100;
return Math.max(MIN_BAR_PCT, Math.round(pct));
});
}
var barPct = measureBarWidths();
rows.forEach(function (row, i) {
var bar = row.querySelector('[data-bar]');
var content = row.querySelector('[data-content]');
bar.style.setProperty('--bar-w', barPct[i] + '%');
content.textContent = ROWS[i];
});
function setLoaded(loaded) {
rows.forEach(function (row, i) {
var content = row.querySelector('[data-content]');
if (loaded) {
row.classList.add('is-loaded');
content.hidden = false;
row.style.animationDelay = (i * ENTER_STAGGER_MS) + 'ms';
} else {
row.classList.remove('is-loaded');
content.hidden = true;
row.style.animationDelay = '';
}
});
list.setAttribute('aria-busy', loaded ? 'false' : 'true');
}
function human() {
if (root.classList.contains('is-demo')) {
root.classList.remove('is-demo');
if (timer) { window.clearInterval(timer); timer = null; }
}
}
reloadBtn.addEventListener('click', function () {
human();
setLoaded(false);
window.setTimeout(function () { setLoaded(true); }, 650);
});
var timer = null;
var start = 0;
var loadedState = false;
function tick(elapsedMs) {
var phase = (elapsedMs % CYCLE_MS) / CYCLE_MS;
var wantLoaded = phase >= LOAD_POINT && phase < RESET_POINT;
if (wantLoaded !== loadedState) {
loadedState = wantLoaded;
setLoaded(loadedState);
}
}
function loop() {
tick(performance.now() - start);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
setLoaded(false);
start = performance.now();
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 스켈레톤 스침·등장 트윈 없이 로딩 완료 상태를 바로 보여준다
setLoaded(true);
}
})();// SkeletonList — vanilla 판(run/381/demo/02-skeleton-list/index.html)의 React 포트.
// 각 행이 로딩 완료 후 실제로 보여줄 글자의 캔버스 실측 폭을 재서 스켈레톤 막대 폭을
// 그 비율로 정한다 — 고정폭이 아니라 내용 길이를 미리 반영한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SkeletonList.module.scss";
export type SkeletonListProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ROWS = [
"상반기 성장 보고서 요약",
"신규 가입자 추이 그래프",
"이번 분기 매출 상위 상품",
"고객 만족도 조사 결과",
"재고 부족 임박 품목 알림",
];
export default function SkeletonList({
duration = "220ms", // vanilla $duration: $dur-quick
easing = "ease-out", // vanilla $easing: $ease-out
color = "#4cd4a6", // vanilla $color: $subject-mint
}: SkeletonListProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const list = root.querySelector<HTMLUListElement>('[data-role="list"]');
const reloadBtn = root.querySelector<HTMLButtonElement>('[data-role="reload"]');
if (!list || !reloadBtn) return;
const rows = Array.prototype.slice.call(list.querySelectorAll("[data-row]")) as HTMLLIElement[];
const CYCLE_MS = 2000; // 캡처 창(2초)의 배수 — 스켈레톤/로딩완료 상태를 오가는 한 사이클
const LOAD_POINT = 0.45; // 사이클 중 로딩 완료로 전환되는 지점
const RESET_POINT = 0.96; // 이 지점부터 다시 스켈레톤으로
const ENTER_STAGGER_MS = 70; // 행마다 등장 지연 간격
const MIN_BAR_PCT = 32; // 막대 폭 하한(%)
const TICK_MS = 50;
// 계산 겹: 각 행이 로딩 완료 후 실제로 보여줄 글자의 캔버스 실측 폭을 재서
// 스켈레톤 막대 폭을 그 비율로 정한다 — 고정폭이 아니라 내용 길이를 미리 반영한다.
function measureBarWidths(): number[] {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const widths = ROWS.map((text) => ctx.measureText(text).width);
const maxW = Math.max.apply(null, widths);
return widths.map((w) => {
const pct = (w / maxW) * 100;
return Math.max(MIN_BAR_PCT, Math.round(pct));
});
}
const barPct = measureBarWidths();
rows.forEach((row, i) => {
const bar = row.querySelector<HTMLSpanElement>("[data-bar]");
const content = row.querySelector<HTMLSpanElement>("[data-content]");
if (!bar || !content) return;
bar.style.setProperty("--bar-w", barPct[i] + "%");
content.textContent = ROWS[i];
});
function setLoaded(loaded: boolean): void {
rows.forEach((row, i) => {
const content = row.querySelector<HTMLSpanElement>("[data-content]");
if (!content) return;
if (loaded) {
row.classList.add("is-loaded");
content.hidden = false;
row.style.animationDelay = i * ENTER_STAGGER_MS + "ms";
} else {
row.classList.remove("is-loaded");
content.hidden = true;
row.style.animationDelay = "";
}
});
list!.setAttribute("aria-busy", loaded ? "false" : "true");
}
function human(): void {
if (root!.classList.contains("is-demo")) {
root!.classList.remove("is-demo");
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
}
function onReload(): void {
human();
setLoaded(false);
window.setTimeout(() => {
setLoaded(true);
}, 650);
}
reloadBtn.addEventListener("click", onReload);
let timer: number | null = null;
let start = 0;
let loadedState = false;
function tick(elapsedMs: number): void {
const phase = (elapsedMs % CYCLE_MS) / CYCLE_MS;
const wantLoaded = phase >= LOAD_POINT && phase < RESET_POINT;
if (wantLoaded !== loadedState) {
loadedState = wantLoaded;
setLoaded(loadedState);
}
}
function loop(): void {
tick(performance.now() - start);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
setLoaded(false);
start = performance.now();
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 스켈레톤 스침·등장 트윈 없이 로딩 완료 상태를 바로 보여준다
setLoaded(true);
}
return () => {
reloadBtn.removeEventListener("click", onReload);
if (timer) {
window.clearInterval(timer);
}
};
}, []);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={`${styles.skl} is-demo`} ref={rootRef} style={vars}>
<ul className={styles.skl__list} data-role="list" aria-busy="true">
{ROWS.map((_, i) => (
<li className={styles.skl__row} data-row={i} key={i}>
<span className={styles.skl__bar} data-bar />
<span className={styles.skl__content} data-content hidden />
</li>
))}
</ul>
<button type="button" className={styles.skl__reload} data-role="reload">
다시 불러오기
</button>
</div>
</main>
);
}02 Skeleton Rows for the First List Load — Measured area 8.956% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · Infinite Scroll Implementation — 9 List UI Basics
The post this demo lives in
Infinite Scroll Implementation — 9 List UI BasicsAmong nine infinite scroll implementation ideas, one slides in new rows with no spinner, one swaps row order on sort cli 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 & tables04 Load-more button list9 Accordion List UI Patterns (Copy-Paste)
Lists & tables02 Odometer counter — splitting the counter value into digits so number markers rollCSS List Style: 9 Marker Design Patterns