03Save-failure retry count — the remaining-retries value drops by one and the label shakes each time a save fails
Each time a save fails the remaining-retries value drops by one, and at that instant the label shakes briefly left and right.
Best forForm saves over an unstable network, a cloud document's sync-failure notice
MAX_RETRYSHAKE_MSBACKOFF_BASE_MS

<main class="stage">
<div class="ser is-demo" id="ser">
<div class="ser__card">
<span class="ser__label" id="serLabel">Save failed</span>
<span class="ser__count" id="serCount">3 retries left</span>
</div>
<button class="ser__btn" id="serBtn" type="button">Retry save</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 흔들림 = 감쇠 사인(sin(t·2π·3)×(1−t)), 대기 = BACKOFF_BASE_MS×2^(실패 순번) — index.html 참조
@include stage($stage-orange);
.ser {
display: grid;
justify-items: center;
gap: $sp-4;
}
.ser__card {
display: grid;
justify-items: center;
gap: $sp-1;
padding: $sp-4 $sp-6;
border-radius: $r-card;
background: $subject-cream;
min-width: 160px;
}
.ser__label {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $error;
}
.ser--out .ser__label { color: $stage-ink; }
.ser__count {
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
font-variant-numeric: tabular-nums;
}
.ser--out .ser__count { color: $error; }
.ser__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.ser__card { padding: $sp-3 $sp-5; min-width: 140px; }
}
@media (prefers-reduced-motion: reduce) {
.ser__btn { transition: none; }
}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);
}
.ser {
display: grid;
justify-items: center;
gap: 16px;
}
.ser__card {
display: grid;
justify-items: center;
gap: 4px;
padding: 16px 24px;
border-radius: 16px;
background: #fff7e6;
min-width: 160px;
}
.ser__label {
font: 700 15px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #c2361a;
}
.ser--out .ser__label {
color: #17141a;
}
.ser__count {
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
font-variant-numeric: tabular-nums;
}
.ser--out .ser__count {
color: #c2361a;
}
.ser__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ser__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.ser__card {
padding: 12px 20px;
min-width: 140px;
}
}
@media (prefers-reduced-motion: reduce) {
.ser__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ser');
var btn = document.getElementById('serBtn');
var card = root.querySelector('.ser__card');
var label = document.getElementById('serLabel');
var countEl = document.getElementById('serCount');
// 상수: 최대 재시도 횟수 · 흔들림 진폭 · 흔들림 지속시간 · 재시도 사이 Idle 배증 기준(가속 ms)
var MAX_RETRY = 3;
var AMP = 5;
var SHAKE_MS = 260;
var BACKOFF_BASE_MS = 260;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var retries = MAX_RETRY;
var timer = null;
// 계산 겹: 흔들림 = 감쇠 사인(sin(t·2π·3)×(1−t)), Idle = BACKOFF_BASE_MS×2^(실패 순번)
function shake(cb) {
if (reduce) { cb(); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / SHAKE_MS);
var x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
card.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (t < 1) requestAnimationFrame(frame); else { card.style.transform = 'translateX(0)'; cb(); }
}
requestAnimationFrame(frame);
}
function fail() {
retries -= 1;
label.textContent = 'Save failed';
root.classList.add('ser--fail');
shake(function () {
if (retries > 0) {
countEl.textContent = 'retries left ' + retries;
var wait = BACKOFF_BASE_MS * Math.pow(2, MAX_RETRY - retries - 1);
timer = setTimeout(fail, wait);
} else {
countEl.textContent = 'Retries exhausted';
root.classList.add('ser--out');
timer = setTimeout(reset, 900);
}
});
}
function reset() {
retries = MAX_RETRY;
label.textContent = 'Save failed';
countEl.textContent = 'retries left ' + retries;
root.classList.remove('ser--out');
root.classList.remove('ser--fail');
timer = setTimeout(fail, BACKOFF_BASE_MS);
}
timer = setTimeout(fail, BACKOFF_BASE_MS);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
reset();
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusErrorRetry.module.scss";
type Box<T> = { current: T };
export default function SaveStatusErrorRetry() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const card = root.querySelector(`.${styles.card}`) as HTMLElement | null;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!card || !label || !countEl || !btn) return;
// 상수: 최대 재시도 횟수 · 흔들림 진폭 · 흔들림 지속시간 · 재시도 사이 대기 배증 기준(가속 ms)
const MAX_RETRY = 3;
const AMP = 5;
const SHAKE_MS = 260;
const BACKOFF_BASE_MS = 260;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let retries = MAX_RETRY;
let timer = 0;
let rafId = 0;
let stopped = false;
// 계산 겹: 흔들림 = 감쇠 사인(sin(t·2π·3)×(1−t)), 대기 = BACKOFF_BASE_MS×2^(실패 순번)
function shake(cb: () => void) {
if (reduce) { cb(); return; }
let start: number | null = null;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / SHAKE_MS);
const x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
card!.style.transform = "translateX(" + x.toFixed(2) + "px)";
if (t < 1) rafId = requestAnimationFrame(frame); else { card!.style.transform = "translateX(0)"; cb(); }
}
rafId = requestAnimationFrame(frame);
}
function fail() {
if (stopped) return;
retries -= 1;
label!.textContent = "저장 실패";
root!.classList.add(styles["ser--fail"]);
shake(() => {
if (retries > 0) {
countEl!.textContent = "남은 재시도 " + retries;
const wait = BACKOFF_BASE_MS * Math.pow(2, MAX_RETRY - retries - 1);
timer = window.setTimeout(fail, wait);
} else {
countEl!.textContent = "재시도 소진";
root!.classList.add(styles["ser--out"]);
timer = window.setTimeout(reset, 900);
}
});
}
function reset() {
retries = MAX_RETRY;
label!.textContent = "저장 실패";
countEl!.textContent = "남은 재시도 " + retries;
root!.classList.remove(styles["ser--out"]);
root!.classList.remove(styles["ser--fail"]);
timer = window.setTimeout(fail, BACKOFF_BASE_MS);
}
timer = window.setTimeout(fail, BACKOFF_BASE_MS);
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
reset();
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ser} ${styles["is-demo"]}`}>
<div className={styles.card}>
<span className={styles.label}>저장 실패</span>
<span className={styles.count}>남은 재시도 3</span>
</div>
<button className={styles.btn} type="button">다시 저장</button>
</div>
);
}03 Save-failure retry count — the remaining-retries value drops by one and the label shakes each time a save fails — Measured area 1.646% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · Autosave Indicator: 9 Save-Status Patterns
The post this demo lives in
Autosave Indicator: 9 Save-Status PatternsIn this autosave collection: One of nine repeats a saving label with dots, another turns elapsed time into a minutes-ago See all demos in this post


