02初回読み込み用リストスケルトン
データが届く前は画面全体が行の形をしたグレーのスケルトンバーで埋まり、実データが到着した瞬間に一括で置き換わる(004の個別スケルトン部品集・349のスクロール追加時スケルトンとは異なる実演 — こちらは初回のみの全体一括置換)。
使いどころ初回アクセス時のリスト・フィード読み込み画面
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">再読み込み</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 = [
'上半期成長レポート要約',
'新規登録者推移グラフ',
'今四半期の売上上位商品',
'顧客満足度調査結果',
'在庫切れ間近の品目通知'
];
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 初回読み込み用リストスケルトン — 計測 累積面積 8.956% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · 無限スクロール 実装 9種 — リスト・テーブルUI基本パターン集
このデモが属する記事
無限スクロール 実装 9種 — リスト・テーブルUI基本パターン集無限スクロール 実装の9種のうち、1つはスピナーなしで新しい行が順に滑り込み、1つは並び替えボタンを押すと行が実際に位置を入れ替えて移動し、1つは横スクロール中に固定した最初の列にだけ影が付きます。 この記事のデモをすべて見る


