06끝 도달 안내 — 남은 항목 값이 0이 된 순간 나타나는 메시지
다음 목록을 부른 결과로 남은 항목 값이 0이 된 상태가 되면 더 부를 목록이 없다는 계산이 확정되고, 그 상태에서는 화면 끝에 닿아도 값이 다시 계산되지 않습니다.
쓰임짧은 알림 목록, 완결된 게시글 모음
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">마지막 알림까지 다 불러왔습니다</span>
</div>
</div>
<p class="emr__hint" id="emrHint">끝에 닿으면 다음 묶음을 부릅니다</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 = '알림 ' + 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 = '남은 항목 값이 0입니다 — 더 닿아도 요청이 없습니다';
}
}, 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 = '끝에 닿으면 다음 묶음을 부릅니다';
}, 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 끝 도달 안내 — 남은 항목 값이 0이 된 순간 나타나는 메시지 — 측정 누적면적 33.577% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지
이 데모가 속한 글
무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지무한 스크롤은 스크롤이 끝에 닿을 때마다 다음 목록을 자동으로 불러와 이어 붙이는 방식인데, 아홉 개 중 하나는 남은 스크롤 거리 값으로 로딩 표시의 투명도를 다시 계산하고, 하나는 이전에 늘어난 높이 값만큼 스크롤 이 글의 데모 전부 보기


