01하단 스피너 페이드 — 남은 스크롤 거리 값으로 투명도를 계산하는 로딩 표시
목록 끝에 가까워진 상태가 되면 남은 스크롤 거리 값을 기준으로 투명도 값을 다시 계산해 로딩 표시가 점점 짙어지고, 새 항목이 도착한 상태가 되면 그 값이 다시 0으로 줄어듭니다.
쓰임SNS 피드 하단, 상품 목록 페이지
THRESHOLD_PXrecomputeFadegetBoundingClientRect

<main class="stage">
<div class="bsf is-demo" id="bsf">
<div class="bsf__view" id="bsfView" tabindex="0">
<ul class="bsf__track" id="bsfTrack" aria-live="polite">
<li class="bsf__sentinel" id="bsfSentinel" aria-hidden="true">
<span class="bsf__ring" id="bsfRing"></span>
</li>
</ul>
</div>
<p class="bsf__hint" id="bsfHint">끝까지 내리면 다음 묶음을 불러옵니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 하단 스피너 페이드 — 스피너 불투명도는 표시 시간이 아니라 남은 스크롤 거리 값에서 매번
// 다시 계산된다(recomputeFade). 회전 각도도 CSS 키프레임이 아니라 rAF 로 매 틱 계산해 더한다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.bsf {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(100%, 280px);
}
.bsf__view {
position: relative;
box-sizing: border-box;
width: 100%;
height: 188px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
padding: $sp-3;
scrollbar-width: thin;
}
.bsf__track {
list-style: none;
margin: 0;
padding: 0 0 $sp-12;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.bsf__row {
box-sizing: border-box;
padding: $sp-3 $sp-4;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
font-weight: 600;
font-size: 12px;
}
.bsf__sentinel {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
pointer-events: none;
}
.bsf__ring {
display: block;
width: 22px;
height: 22px;
opacity: 0;
border-radius: $r-pill;
border: 3px solid rgba(255, 255, 255, .18);
border-top-color: $color;
box-sizing: border-box;
}
.bsf__hint {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .55);
text-align: center;
}
@media (max-width: 340px) {
.bsf { gap: $sp-1; }
.bsf__view { height: 90px; }
.bsf__row { padding: $sp-2 $sp-3; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.bsf__ring { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bsf {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(100%, 280px);
}
.bsf__view {
position: relative;
box-sizing: border-box;
width: 100%;
height: 188px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
padding: 12px;
scrollbar-width: thin;
}
.bsf__track {
list-style: none;
margin: 0;
padding: 0 0 48px;
display: flex;
flex-direction: column;
gap: 8px;
}
.bsf__row {
box-sizing: border-box;
padding: 12px 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-weight: 600;
font-size: 12px;
}
.bsf__sentinel {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
pointer-events: none;
}
.bsf__ring {
display: block;
width: 22px;
height: 22px;
opacity: 0;
border-radius: 999px;
border: 3px solid rgba(255, 255, 255, 0.18);
border-top-color: #2f6df6;
box-sizing: border-box;
}
.bsf__hint {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
text-align: center;
}
@media (max-width: 340px) {
.bsf {
gap: 4px;
}
.bsf__view {
height: 90px;
}
.bsf__row {
padding: 8px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.bsf__ring {
animation: none !important;
}
}(function () {
var view = document.getElementById('bsfView');
var track = document.getElementById('bsfTrack');
var sentinel = document.getElementById('bsfSentinel');
var ring = document.getElementById('bsfRing');
var hint = document.getElementById('bsfHint');
var root = document.getElementById('bsf');
var TOPICS = ['알림', '메모', '사진', '노래', '일정', '문서'];
var THRESHOLD_PX = 96; // 계산에 쓰는 임계 거리(스피너가 서서히 짙어지는 구간의 폭)
var LOAD_DELAY_MS = 620;
var BATCH_SIZE = 3;
var MAX_ROWS = 21;
var AUTOPLAY_STEP_PX = 6;
var rowCount = 0;
var loading = false;
var spinAngle = 0;
var spinRaf = null;
var autoplayTimer = null;
var dir = 1;
function addRow() {
var li = document.createElement('li');
li.className = 'bsf__row';
var topic = TOPICS[rowCount % TOPICS.length];
li.textContent = topic + ' ' + String(rowCount + 1).padStart(2, '0');
track.insertBefore(li, sentinel);
rowCount += 1;
}
for (var i = 0; i < 6; i += 1) { addRow(); }
function spinTick() {
spinAngle = (spinAngle + 9) % 360;
ring.style.transform = 'rotate(' + spinAngle + 'deg)';
if (loading) { spinRaf = requestAnimationFrame(spinTick); }
}
// 계산 겹: 스피너(sentinel) 상단과 스크롤 뷰 하단 사이의 실측 거리 값을 임계 거리로 나눠
// 0~1 진행값을 만들고, 그 값을 그대로 스피너 불투명도로 쓴다 — scrollTop 이 아니라
// getBoundingClientRect 로 잰 실제 화면 위 거리이므로 패딩·보더가 바뀌어도 계산이 맞는다.
function recomputeFade() {
var viewBottom = view.getBoundingClientRect().bottom;
var sentinelTop = sentinel.getBoundingClientRect().top;
var remaining = sentinelTop - viewBottom;
var progress = 1 - Math.min(Math.max(remaining / THRESHOLD_PX, 0), 1);
ring.style.opacity = String(progress);
if (remaining <= 0 && !loading && rowCount < MAX_ROWS) {
startLoad();
}
hint.textContent = (remaining <= 0 && rowCount >= MAX_ROWS)
? '더 내리면 처음으로 되돌아갑니다'
: '끝까지 내리면 다음 묶음을 불러옵니다';
}
function startLoad() {
loading = true;
spinRaf = requestAnimationFrame(spinTick);
setTimeout(function () {
for (var n = 0; n < BATCH_SIZE && rowCount < MAX_ROWS; n += 1) { addRow(); }
loading = false;
cancelAnimationFrame(spinRaf);
recomputeFade();
}, LOAD_DELAY_MS);
}
view.addEventListener('scroll', recomputeFade);
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 (rowCount >= MAX_ROWS && view.scrollTop >= max - 1 && dir !== 0) {
dir = 0;
setTimeout(function () {
while (track.firstChild !== sentinel) { track.removeChild(track.firstChild); }
rowCount = 0;
for (var i = 0; i < 6; i += 1) { addRow(); }
view.scrollTop = 0;
dir = 1;
recomputeFade();
}, 500);
return;
}
if (dir === 0) { return; }
view.scrollTop = Math.min(view.scrollTop + AUTOPLAY_STEP_PX * dir, max);
recomputeFade();
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 60);
}
recomputeFade();
})();import { useEffect, useRef, useState, useCallback } from "react";
import type { ReactNode } from "react";
import styles from "./BottomSpinnerFade.module.scss";
// 하단 스피너 페이드 — 스피너 불투명도는 남은 스크롤 거리 값에서 매번 다시 계산된다.
// 계산 겹: getBoundingClientRect 로 잰 sentinel 상단-뷰 하단 거리를 임계 거리로 나눠 0~1 진행값.
const TOPICS = ["알림", "메모", "사진", "노래", "일정", "문서"];
const THRESHOLD_PX = 96;
const LOAD_DELAY_MS = 620;
const BATCH_SIZE = 3;
const MAX_ROWS = 21;
const AUTOPLAY_STEP_PX = 6;
type Spin = { angle: number };
export default function BottomSpinnerFade() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLUListElement>(null);
const sentinelRef = useRef<HTMLLIElement>(null);
const ringRef = useRef<HTMLSpanElement>(null);
const [rowCount, setRowCount] = useState(6);
const [hint, setHint] = useState("끝까지 내리면 다음 묶음을 불러옵니다");
const loadingRef = useRef(false);
const spinRef = useRef<Spin>({ angle: 0 });
const spinRafRef = useRef<number | null>(null);
const rowCountRef = useRef(6);
rowCountRef.current = rowCount;
const dirRef = useRef(1);
const autoplayRef = useRef<number | null>(null);
const addRows = useCallback((count: number) => {
setRowCount((prev) => Math.min(prev + count, MAX_ROWS));
}, []);
const spinTick = useCallback(() => {
const spin = spinRef.current ?? { angle: 0 };
spin.angle = (spin.angle + 9) % 360;
if (ringRef.current) {
ringRef.current.style.transform = `rotate(${spin.angle}deg)`;
}
if (loadingRef.current) {
spinRafRef.current = window.requestAnimationFrame(spinTick);
}
}, []);
// 계산 겹: 남은 스크롤 거리 값을 임계 거리로 나눠 0~1 진행값을 만들고 그 값을
// 스피너 불투명도로 그대로 쓴다 — 표시 시간이 아니라 거리 값 하나로 짙기가 정해진다.
const recomputeFade = useCallback(() => {
const view = viewRef.current;
const sentinel = sentinelRef.current;
const ring = ringRef.current;
if (!view || !sentinel || !ring) { return; }
const viewBottom = view.getBoundingClientRect().bottom;
const sentinelTop = sentinel.getBoundingClientRect().top;
const remaining = sentinelTop - viewBottom;
const progress = 1 - Math.min(Math.max(remaining / THRESHOLD_PX, 0), 1);
ring.style.opacity = String(progress);
const count = rowCountRef.current ?? 0;
setHint(remaining <= 0 && count >= MAX_ROWS
? "더 내리면 처음으로 되돌아갑니다"
: "끝까지 내리면 다음 묶음을 불러옵니다");
if (remaining <= 0 && !loadingRef.current && count < MAX_ROWS) {
loadingRef.current = true;
spinRafRef.current = window.requestAnimationFrame(spinTick);
window.setTimeout(() => {
addRows(BATCH_SIZE);
loadingRef.current = false;
if (spinRafRef.current !== null) { window.cancelAnimationFrame(spinRafRef.current); }
}, LOAD_DELAY_MS);
}
}, [addRows, spinTick]);
useEffect(() => {
const view = viewRef.current;
if (!view) { return; }
const onScroll = () => recomputeFade();
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;
const count = rowCountRef.current ?? 0;
if (count >= MAX_ROWS && v.scrollTop >= max - 1 && dirRef.current !== 0) {
dirRef.current = 0;
window.setTimeout(() => {
setRowCount(6);
v.scrollTop = 0;
dirRef.current = 1;
recomputeFade();
}, 500);
return;
}
if (dirRef.current === 0) { return; }
v.scrollTop = Math.min(v.scrollTop + AUTOPLAY_STEP_PX, max);
recomputeFade();
}, 60);
}
recomputeFade();
return () => {
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", takeover);
view.removeEventListener("touchstart", takeover);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
if (spinRafRef.current !== null) { window.cancelAnimationFrame(spinRafRef.current); }
};
}, [recomputeFade]);
const rows: ReactNode[] = [];
for (let i = 0; i < rowCount; i += 1) {
rows.push(
<li key={i} className={styles["bsf__row"]}>
{TOPICS[i % TOPICS.length]} {String(i + 1).padStart(2, "0")}
</li>
);
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.bsf} ${styles["is-demo"]}`} id="bsf">
<div ref={viewRef} className={styles["bsf__view"]} tabIndex={0}>
<ul ref={trackRef} className={styles["bsf__track"]} aria-live="polite">
{rows}
<li ref={sentinelRef} className={styles["bsf__sentinel"]} aria-hidden="true">
<span ref={ringRef} className={styles["bsf__ring"]} />
</li>
</ul>
</div>
<p className={styles["bsf__hint"]}>{hint}</p>
</div>
</main>
);
}01 하단 스피너 페이드 — 남은 스크롤 거리 값으로 투명도를 계산하는 로딩 표시 — 측정 누적면적 33.365% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지
이 데모가 속한 글
무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지무한 스크롤은 스크롤이 끝에 닿을 때마다 다음 목록을 자동으로 불러와 이어 붙이는 방식인데, 아홉 개 중 하나는 남은 스크롤 거리 값으로 로딩 표시의 투명도를 다시 계산하고, 하나는 이전에 늘어난 높이 값만큼 스크롤 이 글의 데모 전부 보기


