06一覧の末尾で次のページを取りに行く底のローダー
一覧の一番下のローダーカードが画面に触れたかを IntersectionObserver が見張り、カードを三枚足し、増えた枚数は aria-live 領域が読み上げます。
使いどころ終わりのないフィードとして読まれるコミュニティ・ショッピング一覧
IntersectionObserveraria-livetranslateY

<main class="stage">
<section class="inf is-demo" id="inf" aria-label="無限スクロールの一覧">
<div class="inf__box" id="inf-box" tabindex="0" aria-label="スクロールする一覧の箱">
<ol class="inf__list" id="inf-list">
<li class="inf__card"><span class="inf__no">1</span><span class="inf__t">今日の記録</span></li>
<li class="inf__card"><span class="inf__no">2</span><span class="inf__t">短いメモ</span></li>
<li class="inf__card"><span class="inf__no">3</span><span class="inf__t">週末の予定</span></li>
<li class="inf__card"><span class="inf__no">4</span><span class="inf__t">読みかけの本</span></li>
<li class="inf__card"><span class="inf__no">5</span><span class="inf__t">保存したリンク</span></li>
<li class="inf__card"><span class="inf__no">6</span><span class="inf__t">写真の整理</span></li>
</ol>
<div class="inf__loader" id="inf-loader">
<span class="inf__pulse" aria-hidden="true"></span>
<span class="inf__loader-t">次の一覧を取得中</span>
</div>
</div>
<p class="inf__live" id="inf-live" aria-live="polite">カードを6枚表示しています</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">新着の投稿</span>';
infList.appendChild(li);
}
infLive.textContent = 'カードが3枚届きました';
}
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 一覧の末尾で次のページを取りに行く底のローダー — 計測 累積面積 9.483% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · ページ遷移 ローディング 9選 — コピペで動く
このデモが属する記事
ページ遷移 ローディング 9選 — コピペで動くページ遷移 ローディングとは、画面が次の画面へ切り替わるまでのわずかな待ち時間を目に見える形にする仕組みです。この九つは中央で回るスピナーではなく、プログレスバー・色の帯・点線のプレースホルダー・楽観的な遷移のように、画面全体が入れ替わる瞬 この記事のデモをすべて見る


