08Row stagger reveal — an effect where each list row appears following an order value once the refresh finishes
Once the refresh reaches a finished state, each row's position value and opacity value return to normal after a delay proportional to its own order value, with higher order values appearing later.
Best forA refreshed article list, a refreshed notification list
STAGGER_MSLATENCY_MSrefreshCycle

<main class="stage">
<div class="prsg is-demo" id="prsg">
<div class="prsg__phone" id="prsgPhone">
<div class="prsg__zone">
<span class="prsg__disc" id="prsgDisc"></span>
</div>
<div class="prsg__list" id="prsgList">
<div class="prsg__row"></div>
<div class="prsg__row"></div>
<div class="prsg__row"></div>
<div class="prsg__row"></div>
<div class="prsg__row"></div>
</div>
</div>
<span class="prsg__hint" id="prsgHint">Pull down</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(행마다 순번 * STAGGER_MS 지연)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.prsg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prsg__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-paper;
touch-action: none;
cursor: grab;
}
.prsg__zone {
position: relative;
height: 34px;
display: grid;
place-items: center;
}
.prsg__disc {
width: 20px;
height: 20px;
border-radius: $r-pill;
background: rgba($subject-ink, .22);
transition: background $dur-quick $ease-out;
&--ready { background: $subject-blue-deep; }
&--spin { animation: prsgSpin .6s linear infinite; }
}
@keyframes prsgSpin {
from { transform: rotate(0deg) scale(1); }
to { transform: rotate(360deg) scale(1); }
}
.prsg__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: 0 $sp-4 $sp-3;
}
.prsg__row {
height: 15px;
border-radius: $r-xs;
background: $subject-cream;
opacity: 0;
transform: translateY(6px);
transition: opacity $dur-base $ease-out, transform $dur-base $ease-out;
&--in { opacity: 1; transform: translateY(0); }
}
.prsg__hint {
color: $subject-ink;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prsg__phone { width: 176px; height: 132px; }
.prsg__zone { height: 28px; }
.prsg__list { gap: $sp-1; padding: 0 $sp-3 $sp-2; }
.prsg__row { height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.prsg__disc, .prsg__row { transition: none !important; 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.prsg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prsg__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #ffffff;
touch-action: none;
cursor: grab;
}
.prsg__zone {
position: relative;
height: 34px;
display: grid;
place-items: center;
}
.prsg__disc {
width: 20px;
height: 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
transition: background 220ms ease-out;
}
.prsg__disc--ready {
background: #0a4fe8;
}
.prsg__disc--spin {
animation: prsgSpin 0.6s linear infinite;
}
@keyframes prsgSpin {
from {
transform: rotate(0deg) scale(1);
}
to {
transform: rotate(360deg) scale(1);
}
}
.prsg__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 12px;
}
.prsg__row {
height: 15px;
border-radius: 4px;
background: #fff7e6;
opacity: 0;
transform: translateY(6px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.prsg__row--in {
opacity: 1;
transform: translateY(0);
}
.prsg__hint {
color: #17141a;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prsg__phone {
width: 176px;
height: 132px;
}
.prsg__zone {
height: 28px;
}
.prsg__list {
gap: 4px;
padding: 0 12px 8px;
}
.prsg__row {
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.prsg__disc, .prsg__row {
transition: none !important;
animation: none !important;
}
}(function () {
var phone = document.getElementById('prsgPhone');
var disc = document.getElementById('prsgDisc');
var list = document.getElementById('prsgList');
var hint = document.getElementById('prsgHint');
var root = document.getElementById('prsg');
var rows = list.querySelectorAll('.prsg__row');
// 계산 겹: 새로고침이 끝난 뒤 각 행의 등장 지연은 STAGGER_MS * 행 순번 — 한 번의 CSS
// animation-delay 로 걸지 않고 행마다 setTimeout 을 따로 걸어 순번 계산을 그대로 노출한다.
var REF = 90, MAX = 130, STAGGER_MS = 90, LATENCY_MS = 420;
var raw = 0, target = 0, raf = null;
function apply(raw) {
var pull = Math.min(raw, MAX) * 0.28;
disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, raw / REF)).toFixed(2) + ')';
disc.classList.toggle('prsg__disc--ready', raw >= REF);
hint.textContent = raw >= REF ? 'Release to refresh' : (raw > 4 ? 'Pull more (' + Math.round(raw) + '/' + REF + 'px)' : 'Pull down');
}
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
var timers = [];
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function refreshCycle() {
disc.classList.add('prsg__disc--spin');
hint.textContent = 'Refreshing';
timers.push(setTimeout(function () {
for (var i = 0; i < rows.length; i++) { rows[i].classList.remove('prsg__row--in'); }
// 강제 리플로우 — 클래스를 뗀 상태를 브라우저가 실제로 반영하게 한다.
void list.offsetHeight;
for (var j = 0; j < rows.length; j++) {
(function (idx) {
timers.push(setTimeout(function () { rows[idx].classList.add('prsg__row--in'); }, idx * STAGGER_MS));
})(j);
}
var totalDelay = (rows.length - 1) * STAGGER_MS + 260;
timers.push(setTimeout(function () {
disc.classList.remove('prsg__disc--spin', 'prsg__disc--ready');
hint.textContent = 'Pull down';
raw = 0; apply(0);
}, totalDelay));
}, LATENCY_MS));
}
var dragging = false, startY = 0, startRaw = 0, committing = false;
function onDown(y) { if (committing) { return; } dragging = true; startY = y; startRaw = raw; if (raf) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (raw >= REF) {
committing = true;
refreshCycle();
timers.push(setTimeout(function () { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
} else {
goTo(0);
}
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
for (var k = 0; k < rows.length; k++) { rows[k].classList.add('prsg__row--in'); }
apply(0);
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing) { return; }
autoRaw += 18;
if (autoRaw >= REF) {
raw = REF; apply(raw); autoRaw = 0;
committing = true;
refreshCycle();
timers.push(setTimeout(function () { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
return;
}
raw = autoRaw; apply(raw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } clearTimers(); }
if (!reduce) { autoTimer = setInterval(autoTick, 190); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshRowStaggerReveal.module.scss";
export type PullRefreshRowStaggerRevealProps = {
color?: string;
};
export default function PullRefreshRowStaggerReveal({
color = "#0a4fe8",
}: PullRefreshRowStaggerRevealProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const disc = root.querySelector<HTMLSpanElement>(`.${styles.disc}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !disc || !list || !hint) return;
const rows = Array.from(root.querySelectorAll<HTMLDivElement>(`.${styles.row}`));
// 계산 겹: 새로고침이 끝난 뒤 각 행의 등장 지연은 STAGGER_MS * 행 순번 — 한 번의 CSS
// animation-delay 로 걸지 않고 행마다 setTimeout 을 따로 걸어 순번 계산을 그대로 노출한다.
const REF = 90, MAX = 130, STAGGER_MS = 90, LATENCY_MS = 420;
const readyClass = styles["disc--ready"];
const spinClass = styles["disc--spin"];
const inClass = styles["row--in"];
let raw = 0, target = 0, raf: number | null = null;
function apply(rawV: number) {
const pull = Math.min(rawV, MAX) * 0.28;
disc!.style.transform = "translateY(" + pull.toFixed(1) + "px) scale(" + (0.7 + 0.3 * Math.min(1, rawV / REF)).toFixed(2) + ")";
disc!.classList.toggle(readyClass, rawV >= REF);
hint!.textContent = rawV >= REF ? "놓으면 새로고침" : (rawV > 4 ? "더 당기세요 (" + Math.round(rawV) + "/" + REF + "px)" : "아래로 당기세요");
}
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) raw = target;
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
let timers: number[] = [];
function clearTimers() { timers.forEach((t) => window.clearTimeout(t)); timers = []; }
function refreshCycle() {
disc!.classList.add(spinClass);
hint!.textContent = "새로고침 중";
timers.push(window.setTimeout(() => {
rows.forEach((row) => row.classList.remove(inClass));
void list!.offsetHeight;
rows.forEach((row, idx) => {
timers.push(window.setTimeout(() => row.classList.add(inClass), idx * STAGGER_MS));
});
const totalDelay = (rows.length - 1) * STAGGER_MS + 260;
timers.push(window.setTimeout(() => {
disc!.classList.remove(spinClass, readyClass);
hint!.textContent = "아래로 당기세요";
raw = 0; apply(0);
}, totalDelay));
}, LATENCY_MS));
}
let dragging = false, startY = 0, startRaw = 0, committing = false;
function onDown(y: number) {
if (committing) return;
dragging = true; startY = y; startRaw = raw;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) return;
dragging = false;
if (raw >= REF) {
committing = true;
refreshCycle();
timers.push(window.setTimeout(() => { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
} else {
goTo(0);
}
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
rows.forEach((row) => row.classList.add(inClass));
apply(0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing) return;
autoRaw += 18;
if (autoRaw >= REF) {
raw = REF; apply(raw); autoRaw = 0;
committing = true;
refreshCycle();
timers.push(window.setTimeout(() => { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
return;
}
raw = autoRaw; apply(raw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
clearTimers();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 190); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prsg}>
<div className={styles.phone}>
<div className={styles.zone}>
<span className={styles.disc} style={{ "--accent": color } as any} />
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}08 Row stagger reveal — an effect where each list row appears following an order value once the refresh finishes — Measured area 8.638% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · Pull to Refresh CSS: 9 Loading Patterns
The post this demo lives in
Pull to Refresh CSS: 9 Loading PatternsOne dragged distance value produces a progressively smaller displacement, one rotation value turns in proportion to that See all demos in this post
If you like this effect - similar demos
Loaders05 Gallery Where a Grid-position Value Computes the Reveal OrderLazy Loading Images: 9 CSS Placeholder Patterns
Loaders06 End-of-list reveal — a message appearing once the remaining-item value reaches zeroInfinite Scroll Loading: 9 Essential UI Patterns
Loaders01 Parallel multi-file progress values — each file's progress value updates independently within a list while several downloads run at onceDownload Progress Bar: 9 Loading UI Patterns