09Retry countdown — a list re-attempting once its wait-time value counts down
Once the next page request fails, a wait-time value counts down from a fixed number each moment, and once that value reaches zero the same request automatically fires again.
Best forAn unstable network environment, an offline-resilient list
runCountdownCIRCperformance.now

<main class="stage">
<div class="rcd is-demo" id="rcd">
<div class="rcd__status" id="rcdStatus" role="status">Reaching the end fetches the next batch</div>
<div class="rcd__view" id="rcdView">
<ul class="rcd__track" id="rcdTrack">
<li class="rcd__sentinel" id="rcdSentinel" aria-hidden="true">
<div class="rcd__retry" id="rcdRetry" hidden>
<span class="rcd__seconds" id="rcdSeconds">3.0</span>
<svg class="rcd__arc" viewBox="0 0 36 36" aria-hidden="true">
<circle class="rcd__arc-track" cx="18" cy="18" r="15.9"></circle>
<circle class="rcd__arc-fill" id="rcdArc" cx="18" cy="18" r="15.9"></circle>
</svg>
</div>
</li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 실패 재시도 카운트다운 — runCountdown() 의 rAF 계산이 경과 시간을 ease-out 곡선에 넣어
// 남은 초 값과 링 길이(원둘레 × 남은 비율)를 매 순간 다시 구한다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-ink);
.rcd {
display: flex;
flex-direction: column;
gap: $sp-3;
width: min(100%, 280px);
}
.rcd__status {
padding: $sp-2 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .07);
color: rgba(255, 255, 255, .75);
font-weight: 600;
font-size: 11px;
text-align: center;
}
.rcd__view {
box-sizing: border-box;
width: 100%;
height: 178px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
padding: $sp-3;
scrollbar-width: thin;
}
.rcd__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.rcd__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;
}
.rcd__sentinel {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 44px;
border-radius: $r-sm;
transition: background $duration $easing;
}
.rcd__sentinel--loading {
background: rgba(76, 212, 166, .12);
}
.rcd__retry {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 44px;
height: 44px;
}
.rcd__seconds {
font-weight: 700;
font-size: 12px;
color: $color;
font-variant-numeric: tabular-nums;
}
.rcd__arc {
position: absolute;
inset: 0;
width: 44px;
height: 44px;
transform: rotate(-90deg);
}
.rcd__arc-track {
fill: none;
stroke: rgba(255, 255, 255, .14);
stroke-width: 3;
}
.rcd__arc-fill {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 99.9;
stroke-dashoffset: 0;
}
@media (max-width: 340px) {
.rcd { gap: $sp-1; }
.rcd__status { padding: $sp-1 $sp-2; }
.rcd__view { height: 80px; }
.rcd__row { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.rcd__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);
}
.rcd {
display: flex;
flex-direction: column;
gap: 12px;
width: min(100%, 280px);
}
.rcd__status {
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.75);
font-weight: 600;
font-size: 11px;
text-align: center;
}
.rcd__view {
box-sizing: border-box;
width: 100%;
height: 178px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
padding: 12px;
scrollbar-width: thin;
}
.rcd__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.rcd__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;
}
.rcd__sentinel {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 44px;
border-radius: 8px;
transition: background 220ms ease-out;
}
.rcd__sentinel--loading {
background: rgba(76, 212, 166, 0.12);
}
.rcd__retry {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 44px;
height: 44px;
}
.rcd__seconds {
font-weight: 700;
font-size: 12px;
color: #4cd4a6;
font-variant-numeric: tabular-nums;
}
.rcd__arc {
position: absolute;
inset: 0;
width: 44px;
height: 44px;
transform: rotate(-90deg);
}
.rcd__arc-track {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 3;
}
.rcd__arc-fill {
fill: none;
stroke: #4cd4a6;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 99.9;
stroke-dashoffset: 0;
}
@media (max-width: 340px) {
.rcd {
gap: 4px;
}
.rcd__status {
padding: 4px 8px;
}
.rcd__view {
height: 80px;
}
.rcd__row {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rcd__sentinel {
transition: none !important;
}
}(function () {
var view = document.getElementById('rcdView');
var track = document.getElementById('rcdTrack');
var sentinel = document.getElementById('rcdSentinel');
var statusEl = document.getElementById('rcdStatus');
var retryBox = document.getElementById('rcdRetry');
var secondsEl = document.getElementById('rcdSeconds');
var arc = document.getElementById('rcdArc');
var root = document.getElementById('rcd');
var WAIT_S = 3; // 대기 시간 시작 값(초)
var FIRST_ATTEMPTS_FAIL = 1; // 시드 고정 — 처음 1번은 실패해 카운트다운이 보인다
var LOAD_DELAY_MS = 480;
var MAX_BATCHES = 3;
var CIRC = 2 * Math.PI * 15.9; // 원둘레 값 — 진행 링의 전체 길이
var batches = 0;
var attempts = 0;
var loading = false;
var counting = false;
var rowSeq = 0;
var dir = 1;
var autoplayTimer = null;
var countdownRaf = null;
function addRow() {
var li = document.createElement('li');
li.className = 'rcd__row';
li.textContent = 'Offline queue ' + String(rowSeq + 1).padStart(2, '0');
track.insertBefore(li, sentinel);
rowSeq += 1;
}
for (var i = 0; i < 5; i += 1) { addRow(); }
// 계산 겹: 대기 시간 값은 정적 텍스트가 아니라 rAF 로 매 순간 다시 계산된다 — 경과 시간 값을
// easing(여기선 ease-out 곡선)에 넣어 남은 초와 링 길이(원둘레 × 남은 비율)를 구한다.
function runCountdown(done) {
counting = true;
retryBox.hidden = false;
var t0 = performance.now();
function tick(now) {
var elapsed = (now - t0) / 1000;
var k = Math.min(elapsed / WAIT_S, 1);
var eased = 1 - Math.pow(1 - k, 2); // ease-out — 처음에 빨리 줄고 끝에서 느리게 멈춘다
var remainS = WAIT_S * (1 - eased);
secondsEl.textContent = remainS.toFixed(1);
arc.style.strokeDashoffset = String(CIRC * eased);
if (k < 1) { countdownRaf = requestAnimationFrame(tick); }
else { counting = false; retryBox.hidden = true; done(); }
}
countdownRaf = requestAnimationFrame(tick);
}
function recompute() {
var remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 2 && !loading && !counting && batches < MAX_BATCHES) { startLoad(); }
}
function startLoad() {
loading = true;
sentinel.classList.add('rcd__sentinel--loading');
attempts += 1;
var fail = attempts <= FIRST_ATTEMPTS_FAIL;
setTimeout(function () {
loading = false;
sentinel.classList.remove('rcd__sentinel--loading');
if (fail) {
statusEl.textContent = 'The request failed — retrying shortly';
runCountdown(function () { // 대기 시간 값이 0이 된 순간 같은 요청이 다시 나간다
statusEl.textContent = 'Retrying now';
startLoad();
});
} else {
for (var n = 0; n < 3; n += 1) { addRow(); }
batches += 1;
statusEl.textContent = 'Batch ' + batches + ' arrived';
}
}, 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 (batches >= MAX_BATCHES && view.scrollTop >= max - 1 && dir !== 0) {
dir = 0;
setTimeout(function () {
while (track.firstChild !== sentinel) { track.removeChild(track.firstChild); }
rowSeq = 0; batches = 0; attempts = 0; dir = 1;
for (var i = 0; i < 5; i += 1) { addRow(); }
view.scrollTop = 0;
statusEl.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; }
if (countdownRaf) { cancelAnimationFrame(countdownRaf); }
}
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 "./RetryCountdown.module.scss";
// 실패 재시도 카운트다운 — runCountdown() 의 rAF 계산이 경과 시간을 ease-out 곡선에 넣어
// 남은 초 값과 링 길이(원둘레 × 남은 비율)를 매 순간 다시 구한다.
const WAIT_S = 3;
const FIRST_ATTEMPTS_FAIL = 1;
const LOAD_DELAY_MS = 480;
const MAX_BATCHES = 3;
const CIRC = 2 * Math.PI * 15.9; // 원둘레 값 — 진행 링의 전체 길이
export default function RetryCountdown() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLLIElement>(null);
const arcRef = useRef<SVGCircleElement>(null);
const [rows, setRows] = useState(5);
const [status, setStatus] = useState("끝에 닿으면 다음 묶음을 부릅니다");
const [counting, setCounting] = useState(false);
const [seconds, setSeconds] = useState("3.0");
const loadingRef = useRef(false);
const countingRef = useRef(false);
const attemptsRef = useRef(0);
const batchesRef = useRef(0);
const countdownRafRef = useRef<number | null>(null);
const dirRef = useRef(1);
const autoplayRef = useRef<number | null>(null);
const rowsRef = useRef(5);
rowsRef.current = rows;
// 계산 겹: 대기 시간 값은 정적 텍스트가 아니라 rAF 로 매 순간 다시 계산된다 — 경과 시간 값을
// easing(ease-out)에 넣어 남은 초와 링 길이(원둘레 × 남은 비율)를 구한다.
const runCountdown = useCallback((done: () => void) => {
countingRef.current = true;
setCounting(true);
const t0 = performance.now();
const tick = (now: number) => {
const elapsed = (now - t0) / 1000;
const k = Math.min(elapsed / WAIT_S, 1);
const eased = 1 - Math.pow(1 - k, 2); // ease-out — 처음에 빨리 줄고 끝에서 느리게 멈춘다
const remainS = WAIT_S * (1 - eased);
setSeconds(remainS.toFixed(1));
if (arcRef.current) {
arcRef.current.style.strokeDashoffset = String(CIRC * eased);
}
if (k < 1) {
countdownRafRef.current = window.requestAnimationFrame(tick);
} else {
countingRef.current = false;
setCounting(false);
done(); // 대기 시간 값이 0이 된 순간 같은 요청이 다시 나간다
}
};
countdownRafRef.current = window.requestAnimationFrame(tick);
}, []);
const startLoad = useCallback(() => {
const sentinel = sentinelRef.current;
if (!sentinel || loadingRef.current) { return; }
loadingRef.current = true;
sentinel.classList.add(styles["rcd__sentinel--loading"]);
attemptsRef.current = (attemptsRef.current ?? 0) + 1;
const fail = (attemptsRef.current ?? 0) <= FIRST_ATTEMPTS_FAIL;
window.setTimeout(() => {
loadingRef.current = false;
sentinel.classList.remove(styles["rcd__sentinel--loading"]);
if (fail) {
setStatus("요청이 실패했습니다 — 잠시 뒤 다시 시도합니다");
runCountdown(() => {
setStatus("다시 시도 중입니다");
startLoad();
});
} else {
setRows((prev) => prev + 3);
batchesRef.current = (batchesRef.current ?? 0) + 1;
setStatus("묶음 " + (batchesRef.current ?? 0) + " 도착");
}
}, LOAD_DELAY_MS);
}, [runCountdown]);
const recompute = useCallback(() => {
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 && !countingRef.current && (batchesRef.current ?? 0) < MAX_BATCHES) {
startLoad();
}
}, [startLoad]);
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); }
if (countdownRafRef.current !== null) { window.cancelAnimationFrame(countdownRafRef.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 ((batchesRef.current ?? 0) >= MAX_BATCHES && v.scrollTop >= max - 1 && dirRef.current !== 0) {
dirRef.current = 0;
window.setTimeout(() => {
setRows(5);
batchesRef.current = 0;
attemptsRef.current = 0;
dirRef.current = 1;
v.scrollTop = 0;
setStatus("끝에 닿으면 다음 묶음을 부릅니다");
}, 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 (countdownRafRef.current !== null) { window.cancelAnimationFrame(countdownRafRef.current); }
};
}, [recompute]);
const rowEls = [];
const total = rowsRef.current ?? 5;
for (let i = 0; i < total; i += 1) {
rowEls.push(
<li key={i} className={styles["rcd__row"]}>오프라인 큐 {String(i + 1).padStart(2, "0")}</li>
);
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rcd} ${styles["is-demo"]}`} id="rcd">
<div className={styles["rcd__status"]} role="status">{status}</div>
<div ref={viewRef} className={styles["rcd__view"]}>
<ul className={styles["rcd__track"]}>
{rowEls}
<li ref={sentinelRef} className={styles["rcd__sentinel"]} aria-hidden="true">
{counting && (
<div className={styles["rcd__retry"]}>
<span className={styles["rcd__seconds"]}>{seconds}</span>
<svg className={styles["rcd__arc"]} viewBox="0 0 36 36" aria-hidden="true">
<circle className={styles["rcd__arc-track"]} cx="18" cy="18" r="15.9" />
<circle ref={arcRef} className={styles["rcd__arc-fill"]} cx="18" cy="18" r="15.9" />
</svg>
</div>
)}
</li>
</ul>
</div>
</div>
</main>
);
}09 Retry countdown — a list re-attempting once its wait-time value counts down — Measured area 31.313% · moving frames 23/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
Loaders09 Status bar that counts down and reconnects after a dropped linkPage 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
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