02스켈레톤 행 이어붙이기 — 뷰포트 높이 값으로 미리보기 행 개수를 계산하는 목록
다음 목록을 불러오는 상태가 되면 남은 화면 높이 값을 한 행의 높이 값으로 나눠 미리 보여줄 행 개수를 계산하고, 실제 데이터가 도착한 상태가 되면 그 개수만큼의 자리가 내용으로 바뀝니다.
쓰임뉴스 피드, 이미지 갤러리 그리드
skeletonCountFor--staggerROW_H

<main class="stage">
<div class="sra is-demo" id="sra">
<div class="sra__view" id="sraView">
<ul class="sra__track" id="sraTrack" aria-busy="false">
<li class="sra__sentinel" id="sraSentinel" aria-hidden="true"></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스켈레톤 행 이어붙이기 — 스켈레톤 개수는 skeletonCountFor() 가 실측 여백 높이 값을
// 행 높이 값으로 나눠 매번 다시 정한다. 도착하면 같은 자리(replaceChild)에 실제 내용이 앉는다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-paper);
.sra {
width: min(100%, 280px);
}
.sra__view {
box-sizing: border-box;
width: 100%;
height: 196px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
border: 1px solid rgba(23, 20, 26, .1);
background: $stage-paper;
padding: $sp-3;
scrollbar-width: thin;
}
.sra__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sra__row {
box-sizing: border-box;
height: 32px;
display: flex;
align-items: center;
padding: 0 $sp-3;
border-radius: $r-sm;
background: rgba(76, 212, 166, .14);
color: $subject-ink;
font-weight: 600;
font-size: 12px;
}
.sra__row--skeleton {
background: linear-gradient(
100deg,
rgba(23, 20, 26, .07) 30%,
rgba(23, 20, 26, .14) 45%,
rgba(23, 20, 26, .07) 60%
);
background-size: 240% 100%;
animation: sraShimmer 1.1s ease-in-out infinite;
animation-delay: calc(var(--stagger, 0) * -120ms);
}
.sra__sentinel {
height: 1px;
}
@keyframes sraShimmer {
0% { background-position: 120% 0; }
100% { background-position: -20% 0; }
}
@media (max-width: 340px) {
.sra__view { height: 168px; }
.sra__row { height: 28px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.sra__row--skeleton { animation: none !important; }
}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);
}
.sra {
width: min(100%, 280px);
}
.sra__view {
box-sizing: border-box;
width: 100%;
height: 196px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
border: 1px solid rgba(23, 20, 26, 0.1);
background: #ffffff;
padding: 12px;
scrollbar-width: thin;
}
.sra__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.sra__row {
box-sizing: border-box;
height: 32px;
display: flex;
align-items: center;
padding: 0 12px;
border-radius: 8px;
background: rgba(76, 212, 166, 0.14);
color: #17141a;
font-weight: 600;
font-size: 12px;
}
.sra__row--skeleton {
background: linear-gradient(100deg, rgba(23, 20, 26, 0.07) 30%, rgba(23, 20, 26, 0.14) 45%, rgba(23, 20, 26, 0.07) 60%);
background-size: 240% 100%;
animation: sraShimmer 1.1s ease-in-out infinite;
animation-delay: calc(var(--stagger, 0) * -120ms);
}
.sra__sentinel {
height: 1px;
}
@keyframes sraShimmer {
0% {
background-position: 120% 0;
}
100% {
background-position: -20% 0;
}
}
@media (max-width: 340px) {
.sra__view {
height: 168px;
}
.sra__row {
height: 28px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sra__row--skeleton {
animation: none !important;
}
}(function () {
var view = document.getElementById('sraView');
var track = document.getElementById('sraTrack');
var sentinel = document.getElementById('sraSentinel');
var root = document.getElementById('sra');
var ROW_H = 40; // 행 하나의 높이 값 — 미리보기 행 개수 계산의 분모
var TITLES = ['아침 소식', '주간 인기', '추천 채널', '새 댓글', '읽던 글', '북마크'];
var LOAD_DELAY_MS = 680;
var MAX_BATCHES = 4;
var rowSeq = 0;
var batches = 0;
var loading = false;
var dir = 1;
var autoplayTimer = null;
function realRow(n) {
var li = document.createElement('li');
li.className = 'sra__row';
li.textContent = TITLES[n % TITLES.length] + ' ' + String(n + 1).padStart(2, '0');
return li;
}
function fillInitial() {
var need = Math.ceil(view.clientHeight / ROW_H);
for (var i = 0; i < need; i += 1) {
track.insertBefore(realRow(rowSeq), sentinel);
rowSeq += 1;
}
}
fillInitial();
// 계산 겹: 지금 남아 있는 화면 높이 값을 행 하나의 높이 값으로 나눠 미리 보여줄
// 스켈레톤 행 개수를 구한다 — 화면이 클수록, 스크롤 여유가 클수록 더 많이 깜빡인다.
function skeletonCountFor() {
var remainingViewport = view.getBoundingClientRect().bottom - sentinel.getBoundingClientRect().top;
var visible = Math.max(remainingViewport, view.clientHeight);
return Math.max(2, Math.ceil(visible / ROW_H));
}
function startLoad() {
if (loading || batches >= MAX_BATCHES) { return; }
loading = true;
track.setAttribute('aria-busy', 'true');
var count = skeletonCountFor();
var skeletons = [];
for (var i = 0; i < count; i += 1) {
var li = document.createElement('li');
li.className = 'sra__row sra__row--skeleton';
li.style.setProperty('--stagger', String(i));
track.insertBefore(li, sentinel);
skeletons.push(li);
}
setTimeout(function () {
skeletons.forEach(function (sk, i) {
var real = realRow(rowSeq);
rowSeq += 1;
track.replaceChild(real, sk);
});
loading = false;
batches += 1;
track.setAttribute('aria-busy', 'false');
recompute();
}, LOAD_DELAY_MS);
}
function recompute() {
var remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 24 && !loading) { startLoad(); }
}
view.addEventListener('scroll', recompute);
function userTakesOver() {
if (!root.classList.contains('is-demo')) { return; }
root.classList.remove('is-demo');
stopAutoplay();
}
view.addEventListener('wheel', userTakesOver, { passive: true });
view.addEventListener('touchstart', userTakesOver, { passive: true });
function autoplayTick() {
var max = view.scrollHeight - view.clientHeight;
if (batches >= MAX_BATCHES && view.scrollTop >= max - 1 && dir !== 0) {
dir = 0;
setTimeout(function () {
while (track.firstChild !== sentinel) { track.removeChild(track.firstChild); }
rowSeq = 0; batches = 0;
fillInitial();
view.scrollTop = 0;
dir = 1;
}, 600);
return;
}
if (dir === 0) { return; }
view.scrollTop = Math.min(view.scrollTop + 6, max);
recompute();
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 60);
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import type { ReactNode, CSSProperties } from "react";
import styles from "./SkeletonRowAppend.module.scss";
// 스켈레톤 행 이어붙이기 — skeletonCountFor() 가 실측 여백 높이 값을 행 높이 값으로 나눠
// 미리 보여줄 스켈레톤 행 개수를 다시 구한다. 도착하면 같은 자리에 실제 내용이 앉는다.
const ROW_H = 40;
const TITLES = ["아침 소식", "주간 인기", "추천 채널", "새 댓글", "읽던 글", "북마크"];
const LOAD_DELAY_MS = 680;
const MAX_BATCHES = 4;
export default function SkeletonRowAppend() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLLIElement>(null);
const [rows, setRows] = useState<string[]>([]);
const [skeletons, setSkeletons] = useState<number>(0);
const rowSeqRef = useRef(0);
const batchesRef = useRef(0);
const loadingRef = useRef(false);
const dirRef = useRef(1);
const autoplayRef = useRef<number | null>(null);
const realRow = useCallback((n: number) => {
return TITLES[n % TITLES.length] + " " + String(n + 1).padStart(2, "0");
}, []);
const fillInitial = useCallback(() => {
const view = viewRef.current;
if (!view) { return; }
const need = Math.ceil(view.clientHeight / ROW_H);
const start: string[] = [];
for (let i = 0; i < need; i += 1) {
start.push(realRow(rowSeqRef.current ?? 0));
rowSeqRef.current = (rowSeqRef.current ?? 0) + 1;
}
setRows(start);
}, [realRow]);
// 계산 겹: 지금 남아 있는 화면 높이 값을 행 하나의 높이 값으로 나눠 미리 보여줄
// 스켈레톤 행 개수를 구한다 — 화면이 클수록 더 많이 채운다.
const skeletonCountFor = useCallback(() => {
const view = viewRef.current;
const sentinel = sentinelRef.current;
if (!view || !sentinel) { return 2; }
const remainingViewport = view.getBoundingClientRect().bottom - sentinel.getBoundingClientRect().top;
const visible = Math.max(remainingViewport, view.clientHeight);
return Math.max(2, Math.ceil(visible / ROW_H));
}, []);
const recompute = useCallback(() => {
const view = viewRef.current;
const sentinel = sentinelRef.current;
if (!view || !sentinel) { return; }
const remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 24 && !loadingRef.current && (batchesRef.current ?? 0) < MAX_BATCHES) {
loadingRef.current = true;
const count = skeletonCountFor();
setSkeletons(count);
window.setTimeout(() => {
setRows((prev) => {
const next = [...prev];
for (let i = 0; i < count; i += 1) {
next.push(realRow(rowSeqRef.current ?? 0));
rowSeqRef.current = (rowSeqRef.current ?? 0) + 1;
}
return next;
});
setSkeletons(0);
loadingRef.current = false;
batchesRef.current = (batchesRef.current ?? 0) + 1;
}, LOAD_DELAY_MS);
}
}, [realRow, skeletonCountFor]);
useEffect(() => {
fillInitial();
const view = viewRef.current;
if (!view) { return; }
const onScroll = () => recompute();
view.addEventListener("scroll", onScroll);
const takeover = () => {
rootRef.current?.classList.remove(styles["is-demo"]);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
};
view.addEventListener("wheel", takeover, { passive: true });
view.addEventListener("touchstart", takeover, { passive: true });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayRef.current = window.setInterval(() => {
const v = viewRef.current;
if (!v) { return; }
const max = v.scrollHeight - v.clientHeight;
if ((batchesRef.current ?? 0) >= MAX_BATCHES && v.scrollTop >= max - 1 && dirRef.current !== 0) {
dirRef.current = 0;
window.setTimeout(() => {
rowSeqRef.current = 0;
batchesRef.current = 0;
fillInitial();
v.scrollTop = 0;
dirRef.current = 1;
}, 600);
return;
}
if (dirRef.current === 0) { return; }
v.scrollTop = Math.min(v.scrollTop + 6, max);
recompute();
}, 60);
}
return () => {
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", takeover);
view.removeEventListener("touchstart", takeover);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
};
}, [fillInitial, recompute]);
const skeletonEls: ReactNode[] = [];
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.sra} ${styles["is-demo"]}`} id="sra">
<div ref={viewRef} className={styles["sra__view"]}>
<ul className={styles["sra__track"]} aria-busy={skeletons > 0}>
{rows.map((text, i) => (
<li key={"r" + i} className={styles["sra__row"]}>{text}</li>
))}
{Array.from({ length: skeletons }).map((_, i) => (
<li
key={"s" + i}
className={`${styles["sra__row"]} ${styles["sra__row--skeleton"]}`}
style={{ "--stagger": i } as CSSProperties}
/>
))}
<li ref={sentinelRef} className={styles["sra__sentinel"]} aria-hidden="true" />
</ul>
</div>
</div>
</main>
);
}02 스켈레톤 행 이어붙이기 — 뷰포트 높이 값으로 미리보기 행 개수를 계산하는 목록 — 측정 누적면적 34.218% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지
이 데모가 속한 글
무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지무한 스크롤은 스크롤이 끝에 닿을 때마다 다음 목록을 자동으로 불러와 이어 붙이는 방식인데, 아홉 개 중 하나는 남은 스크롤 거리 값으로 로딩 표시의 투명도를 다시 계산하고, 하나는 이전에 늘어난 높이 값만큼 스크롤 이 글의 데모 전부 보기


