06Footer loader that fetches the next page at the end of the list
An IntersectionObserver watches whether the loader card at the bottom of the list touches the screen, appends three cards, and an aria-live region announces how many arrived.
Best forCommunity and shopping lists that are read as an endless feed
IntersectionObserveraria-livetranslateY

<main class="stage">
<section class="inf is-demo" id="inf" aria-label="Infinite scroll list">
<div class="inf__box" id="inf-box" tabindex="0" aria-label="Scrollable list">
<ol class="inf__list" id="inf-list">
<li class="inf__card"><span class="inf__no">1</span><span class="inf__t">Today's log</span></li>
<li class="inf__card"><span class="inf__no">2</span><span class="inf__t">A short note</span></li>
<li class="inf__card"><span class="inf__no">3</span><span class="inf__t">Weekend plan</span></li>
<li class="inf__card"><span class="inf__no">4</span><span class="inf__t">The book I left</span></li>
<li class="inf__card"><span class="inf__no">5</span><span class="inf__t">Saved links</span></li>
<li class="inf__card"><span class="inf__no">6</span><span class="inf__t">Photo cleanup</span></li>
</ol>
<div class="inf__loader" id="inf-loader">
<span class="inf__pulse" aria-hidden="true"></span>
<span class="inf__loader-t">Fetching the next page</span>
</div>
</div>
<p class="inf__live" id="inf-live" aria-live="polite">Showing 6 cards</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 무한 스크롤 바닥 로더 — 목록 상자 안에서만 스크롤이 일어난다.
// 무대(480×300)는 stage 믹스인이 overflow: hidden 으로 잡아 두므로 무대 자체가 넘치면 안 된다.
// IntersectionObserver 는 CSS 시계로 못 재니, 미리보기 루프는 로더가 맥박 치고
// 마지막 카드 셋이 animation-delay 시차로 translateY 올라오는 모습만 보여 준다.
// 카드가 사라지는 장면은 재현하지 않는다 — 복귀는 86% 안에서 한 프레임 컷이다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.inf {
box-sizing: border-box;
display: grid;
gap: $sp-2;
width: min(300px, 94vw);
color: $color;
}
.inf * { box-sizing: border-box; }
// ── 목록 상자: 스크롤은 여기 안에서만 ──
.inf__box {
height: 196px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
display: grid;
align-content: start;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
}
.inf__box:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.inf__list {
display: grid;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.inf__card {
display: flex;
align-items: center;
gap: $sp-2;
height: 32px;
padding: 0 $sp-3;
border-radius: $r-control;
background: $subject-cream;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.inf__no {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(23, 20, 26, .12);
font-size: 11px;
font-weight: 700;
}
// ── 바닥 로더 카드 ──
.inf__loader {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
height: 34px;
border: 2px dashed rgba(23, 20, 26, .38);
border-radius: $r-control;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.inf__pulse {
width: 12px;
height: 12px;
border-radius: 50%;
background: $color;
}
.inf__live {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
// ── 실제로 붙은 카드: 한 번만 올라온다 (사람이 스크롤한 경로) ──
@keyframes inf-in {
from { transform: translateY(6px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.inf__card--in {
animation-name: inf-in;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
// ── 격자 미리보기 전용 2초 루프 ──
// 카드는 한순간도 사라지지 않는다. 올라온 뒤 86% 에 제자리로 한 프레임 컷.
// 내려간 폭(6px)은 카드 사이 간격(8px)보다 작다 — 되돌아간 순간에도 카드끼리 붙어 보이지 않는다.
@keyframes inf-rise {
0%, 8% { transform: translateY(6px); }
34%, 86% { transform: translateY(0); }
86.01%, 100% { transform: translateY(6px); }
}
@keyframes inf-pulse {
0% { transform: scale(1); }
22% { transform: scale(1.5); }
44% { transform: scale(1); }
66% { transform: scale(1.5); }
88%, 100% { transform: scale(1); }
}
.inf.is-demo {
// 지연이 걸린 애니메이션은 backwards 가 없으면 첫 프레임에 기본 상태로 번쩍인다
.inf__card:nth-last-child(-n + 3) {
animation-name: inf-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.inf__card:nth-last-child(2) { animation-delay: 180ms; }
.inf__card:nth-last-child(1) { animation-delay: 360ms; }
.inf__pulse {
animation-name: inf-pulse;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.inf { width: min(252px, 96vw); }
.inf__box { height: 144px; padding: $sp-1; }
.inf__card { height: 28px; padding: 0 $sp-2; font-size: 11px; }
.inf__no { width: 16px; height: 16px; font-size: 10px; }
.inf__loader { height: 30px; font-size: 10px; }
.inf__pulse { width: 10px; height: 10px; }
.inf__live { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.inf.is-demo .inf__card,
.inf.is-demo .inf__pulse,
.inf__card--in { animation: none !important; }
// 움직임만 끄고 "이미 도착한 카드" 라는 결과 상태는 남긴다
.inf.is-demo .inf__card,
.inf__card--in { transform: translateY(0) !important; opacity: 1 !important; }
.inf.is-demo .inf__pulse { transform: scale(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.inf {
box-sizing: border-box;
display: grid;
gap: 8px;
width: min(300px, 94vw);
color: #17141a;
}
.inf * {
box-sizing: border-box;
}
.inf__box {
height: 196px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
display: grid;
align-content: start;
gap: 8px;
padding: 8px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
}
.inf__box:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.inf__list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.inf__card {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px;
border-radius: 12px;
background: #fff7e6;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.inf__no {
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.12);
font-size: 11px;
font-weight: 700;
}
.inf__loader {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 34px;
border: 2px dashed rgba(23, 20, 26, 0.38);
border-radius: 12px;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.inf__pulse {
width: 12px;
height: 12px;
border-radius: 50%;
background: #17141a;
}
.inf__live {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
@keyframes inf-in {
from {
transform: translateY(6px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.inf__card--in {
animation-name: inf-in;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
@keyframes inf-rise {
0%, 8% {
transform: translateY(6px);
}
34%, 86% {
transform: translateY(0);
}
86.01%, 100% {
transform: translateY(6px);
}
}
@keyframes inf-pulse {
0% {
transform: scale(1);
}
22% {
transform: scale(1.5);
}
44% {
transform: scale(1);
}
66% {
transform: scale(1.5);
}
88%, 100% {
transform: scale(1);
}
}
.inf.is-demo .inf__card:nth-last-child(-n+3) {
animation-name: inf-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.inf.is-demo .inf__card:nth-last-child(2) {
animation-delay: 180ms;
}
.inf.is-demo .inf__card:nth-last-child(1) {
animation-delay: 360ms;
}
.inf.is-demo .inf__pulse {
animation-name: inf-pulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.inf {
width: min(252px, 96vw);
}
.inf__box {
height: 144px;
padding: 4px;
}
.inf__card {
height: 28px;
padding: 0 8px;
font-size: 11px;
}
.inf__no {
width: 16px;
height: 16px;
font-size: 10px;
}
.inf__loader {
height: 30px;
font-size: 10px;
}
.inf__pulse {
width: 10px;
height: 10px;
}
.inf__live {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.inf.is-demo .inf__card,
.inf.is-demo .inf__pulse,
.inf__card--in {
animation: none !important;
}
.inf.is-demo .inf__card,
.inf__card--in {
transform: translateY(0) !important;
opacity: 1 !important;
}
.inf.is-demo .inf__pulse {
transform: scale(1) !important;
}
}// 바닥 로더가 스크롤로 화면에 닿으면 IntersectionObserver 가 잡아 카드 3장을 붙인다.
// 미리보기(.is-demo)에서는 시계가 CSS 라 관찰을 걸지 않는다 — 사람이 손을 대는 순간 시작한다.
var inf = document.getElementById('inf');
var infBox = document.getElementById('inf-box');
var infList = document.getElementById('inf-list');
var infLoader = document.getElementById('inf-loader');
var infLive = document.getElementById('inf-live');
var infN = infList.children.length;
var infIo = null;
function infAdd() {
for (var k = 0; k < 3; k++) {
infN++;
var li = document.createElement('li');
li.className = 'inf__card inf__card--in';
li.style.animationDelay = (k * 90) + 'ms';
li.innerHTML = '<span class="inf__no">' + infN + '</span><span class="inf__t">Just arrived</span>';
infList.appendChild(li);
}
infLive.textContent = '3 more cards arrived';
}
function infWake() {
if (infIo) return;
inf.classList.remove('is-demo');
infIo = new IntersectionObserver(function (es) {
if (es[0] && es[0].isIntersecting) infAdd();
}, { root: infBox, threshold: 0.9 });
infIo.observe(infLoader);
}
['wheel', 'pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
infBox.addEventListener(ev, infWake);
});
// 미리보기에서는 바닥(로더가 있는 쪽)부터 보여 준다
if (inf.classList.contains('is-demo') && infBox.scrollHeight > infBox.clientHeight) {
infBox.scrollTop = infBox.scrollHeight;
}import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InfiniteFooterLoader.module.scss";
export type InfiniteFooterLoaderProps = {
/** 처음부터 들어 있는 카드 이름들 */
items?: string[];
/** 새로 붙는 카드 이름 */
nextLabel?: string;
loaderLabel?: string;
boxLabel?: string;
groupLabel?: string;
/** aria-live 로 읽히는 첫 안내 */
startMessage?: string;
/** 새 카드가 붙었을 때의 안내 */
moreMessage?: string;
/** 한 번에 붙는 장수 */
step?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 이벤트 제네릭을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type Box<T> = { current: T };
type ScrollBox = { scrollTop: number; scrollHeight: number; clientHeight: number };
type IoEntry = { isIntersecting: boolean };
type IoLike = { observe(el: unknown): void; disconnect(): void };
type IoCtor = new (cb: (e: IoEntry[]) => void, opt: { root: unknown; threshold: number }) => IoLike;
const DEFAULT_ITEMS = ["Today's log", "A short note", "Weekend plan", "The book I left", "Saved links", "Photo cleanup"];
export default function InfiniteFooterLoader({
items = DEFAULT_ITEMS,
nextLabel = "Just arrived",
loaderLabel = "Fetching the next page",
boxLabel = "Scrollable list",
groupLabel = "Infinite scroll list",
startMessage = "Showing 6 cards",
moreMessage = "3 more cards arrived",
step = 3,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: InfiniteFooterLoaderProps) {
const base = items.length;
const [extra, setExtra] = useState(0);
const [live, setLive] = useState(demo);
const [msg, setMsg] = useState(startMessage);
const boxRef = useRef<any>(null) as Box<any>;
const loaderRef = useRef<any>(null) as Box<any>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 미리보기에서는 바닥(로더가 있는 쪽)부터 보여 준다
useEffect(() => {
const b: ScrollBox | null = boxRef.current;
if (!live || !b) return;
if (b.scrollHeight > b.clientHeight) b.scrollTop = b.scrollHeight;
}, [live]);
// 로더가 상자 안에 들어오면 다음 묶음을 붙인다. 미리보기 동안에는 관찰하지 않는다
useEffect(() => {
if (live) return;
const box = boxRef.current;
const target = loaderRef.current;
if (!box || !target) return;
const Ctor = IntersectionObserver as unknown as IoCtor;
const io = new Ctor(
(es: IoEntry[]) => {
if (!es[0] || !es[0].isIntersecting) return;
setExtra((n: number) => n + step);
setMsg(moreMessage);
},
{ root: box, threshold: 0.9 },
);
io.observe(target);
return () => io.disconnect();
}, [live]);
function wake() {
if (live) setLive(false);
}
const names: string[] = items.slice();
for (let i = 0; i < extra; i++) names.push(nextLabel);
const cls = [styles.inf, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div
className={styles.box}
ref={boxRef}
tabIndex={0}
aria-label={boxLabel}
onWheel={wake}
onPointerDown={wake}
onKeyDown={wake}
onTouchStart={wake}
>
<ol className={styles.list}>
{names.map((name, i) => (
<li
className={i < base ? styles.card : styles.card + " " + styles.isIn}
key={String(i)}
style={i < base ? undefined : { animationDelay: `${((i - base) % step) * 90}ms` }}
>
<span className={styles.no}>{i + 1}</span>
<span className={styles.t}>{name}</span>
</li>
))}
</ol>
<div className={styles.loader} ref={loaderRef}>
<span className={styles.pulse} aria-hidden="true" />
<span className={styles.loaderT}>{loaderLabel}</span>
</div>
</div>
<p className={styles.live} aria-live="polite">{msg}</p>
</section>
);
}06 Footer loader that fetches the next page at the end of the list — Measured area 9.483% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · Page Loading Animation CSS: 9 Route-Change Demos
The post this demo lives in
Page Loading Animation CSS: 9 Route-Change DemosPage loading animation css is the feedback layer covering the moment one screen gives way to the next. The nine here are See all demos in this post
If you like this effect - similar demos
Loaders06 End-of-list reveal — a message appearing once the remaining-item value reaches zeroInfinite Scroll Loading: 9 Essential UI Patterns
Loaders09 A Percent Loader That Smoothly Follows Uneven Real Progress and Pulses the Logo at the EndLogo Loading Animation: 9 Progress-Driven Marks
Loaders04 Button That Shakes Briefly on Failure Then Reverts to Its LabelLoading Button UI — 9 States, Spinner to Success