01Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distance
Once the list nears its end, the remaining scroll distance is used to recompute an opacity value that gradually deepens the loading indicator, and once new items arrive that value falls back to zero.
Best forThe bottom of a social feed, a product listing page
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">Scroll to the bottom to load the next batch</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 = ['Alert', 'Note', 'Photo', 'Song', 'Event', 'Doc'];
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)
? 'Scroll further to return to the start'
: 'Scroll to the bottom to load the next batch';
}
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 Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distance — Measured area 33.365% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Infinite Scroll Loading: 9 Essential UI Patterns
The post this demo lives in
Infinite Scroll Loading: 9 Essential UI PatternsOne indicator recomputes opacity from the remaining scroll distance, one offsets scroll position by the height added abo See all demos in this post
If you like this effect - similar demos
Loaders03 Multi-user overlap — a row computing name-tag width from concurrent typers9 Typing Indicator CSS Patterns (Chat UI)
Loaders02 Circular percent ring computed from its own circumferenceFile Upload Progress Bar Loader: 9 UI Demos
Loaders05 Scroll edge fade9 CSS AI Chat Loading Animations to Copy-Paste