06End-of-list reveal — a message appearing once the remaining-item value reaches zero
Once fetching the next page brings the remaining-item value to zero, the computation confirms there is nothing left to fetch, and reaching the edge again in that state no longer recomputes the value.
Best forA short notification list, a finite collection of posts
revealEndREVEAL_PXrequestAnimationFrame

<main class="stage">
<div class="emr is-demo" id="emr">
<div class="emr__view" id="emrView">
<ul class="emr__track" id="emrTrack">
<li class="emr__sentinel" id="emrSentinel" aria-hidden="true"></li>
</ul>
<div class="emr__end" id="emrEnd" role="status" hidden>
<span class="emr__end-check" id="emrCheck" aria-hidden="true"></span>
<span class="emr__end-text">Loaded up to the last notification</span>
</div>
</div>
<p class="emr__hint" id="emrHint">Reaching the end fetches the next batch</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 끝 도달 안내 — 남은 항목 값이 0으로 확정된 순간에만 revealEnd() 의 rAF 감쇠 계산이
// 실행된다. 체크는 BD16 규칙대로 같은 꼭짓점(left/top 동일)에서 출발하는 막대 두 개.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.emr {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(100%, 280px);
}
.emr__view {
position: relative;
box-sizing: border-box;
width: 100%;
height: 192px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
padding: $sp-3;
scrollbar-width: thin;
}
.emr__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.emr__row {
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
font-weight: 600;
font-size: 12px;
}
.emr__sentinel {
height: 6px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
transition: background $duration $easing;
}
.emr__sentinel--loading {
background: $color;
}
.emr__end {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
box-sizing: border-box;
margin-top: $sp-2;
padding: $sp-3;
border-radius: $r-sm;
background: rgba(76, 212, 166, .16);
}
.emr__end-text {
font-weight: 700;
font-size: 11px;
color: $color;
}
// 체크 막대 두 개 — 같은 꼭짓점(4px 8px)에서 출발해 rotate 로 벌어진다 (BD16)
.emr__end-check {
position: relative;
display: inline-block;
width: 12px;
height: 12px;
}
.emr__end-check::before,
.emr__end-check::after {
content: "";
position: absolute;
left: 4px;
top: 8px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform-origin: left center;
}
.emr__end-check::before {
width: 5px;
transform: rotate(45deg);
}
.emr__end-check::after {
width: 9px;
transform: rotate(-45deg);
}
.emr__hint {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .55);
text-align: center;
}
@media (max-width: 340px) {
.emr { gap: $sp-1; }
.emr__view { height: 90px; }
.emr__row { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.emr__sentinel { transition: 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);
}
.emr {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(100%, 280px);
}
.emr__view {
position: relative;
box-sizing: border-box;
width: 100%;
height: 192px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
padding: 12px;
scrollbar-width: thin;
}
.emr__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.emr__row {
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-weight: 600;
font-size: 12px;
}
.emr__sentinel {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
transition: background 300ms ease-out;
}
.emr__sentinel--loading {
background: #4cd4a6;
}
.emr__end {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
margin-top: 8px;
padding: 12px;
border-radius: 8px;
background: rgba(76, 212, 166, 0.16);
}
.emr__end-text {
font-weight: 700;
font-size: 11px;
color: #4cd4a6;
}
.emr__end-check {
position: relative;
display: inline-block;
width: 12px;
height: 12px;
}
.emr__end-check::before,
.emr__end-check::after {
content: "";
position: absolute;
left: 4px;
top: 8px;
height: 2px;
border-radius: 999px;
background: #4cd4a6;
transform-origin: left center;
}
.emr__end-check::before {
width: 5px;
transform: rotate(45deg);
}
.emr__end-check::after {
width: 9px;
transform: rotate(-45deg);
}
.emr__hint {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
text-align: center;
}
@media (max-width: 340px) {
.emr {
gap: 4px;
}
.emr__view {
height: 90px;
}
.emr__row {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.emr__sentinel {
transition: none !important;
}
}(function () {
var view = document.getElementById('emrView');
var track = document.getElementById('emrTrack');
var sentinel = document.getElementById('emrSentinel');
var endBox = document.getElementById('emrEnd');
var hint = document.getElementById('emrHint');
var root = document.getElementById('emr');
var TOTAL_PAGES = 3;
var PAGE_SIZE = 4;
var LOAD_DELAY_MS = 560;
var REVEAL_PX = 34; // 안내 문구가 아래에서 밀려 올라오는 거리 값
var page = 0;
var loading = false;
var exhausted = false;
var rowSeq = 0;
var revealRaf = null;
var dir = 1;
var autoplayTimer = null;
function addRow() {
var li = document.createElement('li');
li.className = 'emr__row';
li.textContent = 'Alert ' + String(rowSeq + 1).padStart(2, '0');
track.insertBefore(li, sentinel);
rowSeq += 1;
}
for (var i = 0; i < 4; i += 1) { addRow(); }
// 계산 겹: 안내 문구의 올라오는 정도를 고정 트랜지션이 아니라 rAF 로 매 프레임 계산한다 —
// 시작 값을 REVEAL_PX 로 두고 목표 0 까지 지수 감쇠로 당긴다. 남은 항목 값이 0이 되기 전엔
// 이 계산 자체가 실행되지 않는다(한 번 확정되면 다시 계산되지 않는다).
function revealEnd() {
endBox.hidden = false;
var y = REVEAL_PX;
function tick() {
y += (0 - y) * 0.18;
endBox.style.transform = 'translateY(' + y.toFixed(2) + 'px)';
if (y > 0.4) { revealRaf = requestAnimationFrame(tick); }
else { endBox.style.transform = 'translateY(0px)'; revealRaf = null; }
}
revealRaf = requestAnimationFrame(tick);
}
function recompute() {
if (exhausted) { return; } // 남은 항목 값이 0 — 끝에 닿아도 다시 계산하지 않는다
var remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 2 && !loading) { startLoad(); }
}
function startLoad() {
loading = true;
sentinel.classList.add('emr__sentinel--loading');
setTimeout(function () {
page += 1;
for (var n = 0; n < PAGE_SIZE; n += 1) { addRow(); }
sentinel.classList.remove('emr__sentinel--loading');
loading = false;
if (page >= TOTAL_PAGES) {
exhausted = true; // 남은 항목 값 0 확정 — 이 계산은 두 번 다시 일어나지 않는다
sentinel.remove();
revealEnd();
hint.textContent = 'The remaining-item value is zero — reaching the edge sends no request';
}
}, LOAD_DELAY_MS);
}
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 (exhausted && view.scrollTop >= max - 1 && dir !== 0) {
dir = 0;
setTimeout(function () {
while (track.firstChild) { track.removeChild(track.firstChild); }
rowSeq = 0; page = 0; exhausted = false; dir = 1;
endBox.hidden = true;
track.appendChild(sentinel);
for (var i = 0; i < 4; i += 1) { addRow(); }
view.scrollTop = 0;
hint.textContent = 'Reaching the end fetches the next batch';
}, 700);
return;
}
if (dir === 0) { return; }
view.scrollTop = Math.min(view.scrollTop + 5, 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 styles from "./EndMessageReveal.module.scss";
// 끝 도달 안내 — 남은 항목 값이 0으로 확정된 순간에만 revealEnd() 의 rAF 감쇠 계산이 실행된다.
// 계산 겹: 시작 값 REVEAL_PX 에서 목표 0 까지 지수 감쇠로 매 프레임 당긴다.
const TOTAL_PAGES = 3;
const PAGE_SIZE = 4;
const LOAD_DELAY_MS = 560;
const REVEAL_PX = 34;
export default function EndMessageReveal() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLLIElement>(null);
const endRef = useRef<HTMLDivElement>(null);
const [rows, setRows] = useState(4);
const [exhausted, setExhausted] = useState(false);
const [hint, setHint] = useState("끝에 닿으면 다음 묶음을 부릅니다");
const loadingRef = useRef(false);
const pageRef = useRef(0);
const rowsRef = useRef(4);
rowsRef.current = rows;
const exhaustedRef = useRef(false);
exhaustedRef.current = exhausted;
const revealRafRef = useRef<number | null>(null);
const dirRef = useRef(1);
const autoplayRef = useRef<number | null>(null);
// 계산 겹: 안내 문구의 올라오는 정도를 고정 트랜지션이 아니라 rAF 로 매 프레임 계산한다 —
// 남은 항목 값이 0이 되기 전엔 이 계산 자체가 실행되지 않는다.
const revealEnd = useCallback(() => {
const endBox = endRef.current;
if (!endBox) { return; }
let y = REVEAL_PX;
const tick = () => {
y += (0 - y) * 0.18;
endBox.style.transform = "translateY(" + y.toFixed(2) + "px)";
if (y > 0.4) {
revealRafRef.current = window.requestAnimationFrame(tick);
} else {
endBox.style.transform = "translateY(0px)";
revealRafRef.current = null;
}
};
revealRafRef.current = window.requestAnimationFrame(tick);
}, []);
const recompute = useCallback(() => {
if (exhaustedRef.current) { return; } // 남은 항목 값 0 — 끝에 닿아도 다시 계산하지 않는다
const view = viewRef.current;
const sentinel = sentinelRef.current;
if (!view || !sentinel) { return; }
const remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 2 && !loadingRef.current) {
loadingRef.current = true;
sentinel.classList.add(styles["emr__sentinel--loading"]);
window.setTimeout(() => {
pageRef.current = (pageRef.current ?? 0) + 1;
setRows((prev) => prev + PAGE_SIZE);
sentinel.classList.remove(styles["emr__sentinel--loading"]);
loadingRef.current = false;
if ((pageRef.current ?? 0) >= TOTAL_PAGES) {
setExhausted(true); // 남은 항목 값 0 확정 — 이 계산은 두 번 다시 일어나지 않는다
revealEnd();
setHint("남은 항목 값이 0입니다 — 더 닿아도 요청이 없습니다");
}
}, LOAD_DELAY_MS);
}
}, [revealEnd]);
useEffect(() => {
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 (exhaustedRef.current && v.scrollTop >= max - 1 && dirRef.current !== 0) {
dirRef.current = 0;
window.setTimeout(() => {
setRows(4);
pageRef.current = 0;
setExhausted(false);
dirRef.current = 1;
v.scrollTop = 0;
setHint("끝에 닿으면 다음 묶음을 부릅니다");
}, 700);
return;
}
if (dirRef.current === 0) { return; }
v.scrollTop = Math.min(v.scrollTop + 5, max);
recompute();
}, 60);
}
return () => {
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", takeover);
view.removeEventListener("touchstart", takeover);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
if (revealRafRef.current !== null) { window.cancelAnimationFrame(revealRafRef.current); }
};
}, [recompute]);
const rowEls = [];
for (let i = 0; i < rows; i += 1) {
rowEls.push(
<li key={i} className={styles["emr__row"]}>알림 {String(i + 1).padStart(2, "0")}</li>
);
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.emr} ${styles["is-demo"]}`} id="emr">
<div ref={viewRef} className={styles["emr__view"]}>
<ul className={styles["emr__track"]}>
{rowEls}
{!exhausted && (
<li ref={sentinelRef} className={styles["emr__sentinel"]} aria-hidden="true" />
)}
</ul>
{exhausted && (
<div ref={endRef} className={styles["emr__end"]} role="status">
<span className={styles["emr__end-check"]} aria-hidden="true" />
<span className={styles["emr__end-text"]}>마지막 알림까지 다 불러왔습니다</span>
</div>
)}
</div>
<p className={styles["emr__hint"]}>{hint}</p>
</div>
</main>
);
}06 End-of-list reveal — a message appearing once the remaining-item value reaches zero — Measured area 33.577% · moving frames 21/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
Loaders06 Footer loader that fetches the next page at the end of the listPage Loading Animation CSS: 9 Route-Change Demos
Loaders08 Row stagger reveal — an effect where each list row appears following an order value once the refresh finishesPull to Refresh CSS: 9 Loading Patterns
Loaders08 Finished-item position-value move — a finished item's position value shifts into a set area of the listDownload Progress Bar: 9 Loading UI Patterns