09失敗リトライのカウントダウン — 待機時間の値が減る分だけ再試行するリスト
次の一覧の要求が失敗した状態になると、待機時間の値が決まった数から毎瞬間減っていき、その値が0になった状態になると同じ要求が自動的に再び送られます。
使いどころ不安定なネットワーク環境、オフライン対応のリスト
runCountdownCIRCperformance.now

<main class="stage">
<div class="rcd is-demo" id="rcd">
<div class="rcd__status" id="rcdStatus" role="status">終端に達すると次のかたまりを要求します</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 = 'オフラインキュー ' + 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 = '要求が失敗しました — まもなく再試行します';
runCountdown(function () { // 대기 시간 값이 0이 된 순간 같은 요청이 다시 나간다
statusEl.textContent = '再試行中です';
startLoad();
});
} else {
for (var n = 0; n < 3; n += 1) { addRow(); }
batches += 1;
statusEl.textContent = 'かたまり ' + batches + ' 到着';
}
}, 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 = '終端に達すると次のかたまりを要求します';
}, 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 失敗リトライのカウントダウン — 待機時間の値が減る分だけ再試行するリスト — 計測 累積面積 31.313% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで
このデモが属する記事
無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで9つのうち1つは残りスクロール距離の値からローディング表示の不透明度を求め直し、1つは上に追加された高さの値の分だけスクロール位置を補正して画面がずれないようにし、もう1つは見えているインデックス範囲の値だけを残し他は描きません。無限スクロ この記事のデモをすべて見る


