03保存失敗の再試行回数 — 保存が失敗するたびに残り再試行回数の値が1つ減り、文言が揺れる方式
保存が失敗するたびに残り再試行回数の値が1つ減り、その瞬間に文言が短く左右に揺れます。
使いどころ不安定な回線でのフォーム保存、クラウド文書の同期失敗表示
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">保存失敗</span>
<span class="ser__count" id="serCount">残り再試行 3</span>
</div>
<button class="ser__btn" id="serBtn" type="button">再保存</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');
// 상수: 최대 재시도 횟수 · 흔들림 진폭 · 흔들림 지속시간 · 재시도 사이 待機 배증 기준(가속 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)), 待機 = 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 = '保存失敗';
root.classList.add('ser--fail');
shake(function () {
if (retries > 0) {
countEl.textContent = '残り再試行 ' + retries;
var wait = BACKOFF_BASE_MS * Math.pow(2, MAX_RETRY - retries - 1);
timer = setTimeout(fail, wait);
} else {
countEl.textContent = '再試行を使い切りました';
root.classList.add('ser--out');
timer = setTimeout(reset, 900);
}
});
}
function reset() {
retries = MAX_RETRY;
label.textContent = '保存失敗';
countEl.textContent = '残り再試行 ' + 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 保存失敗の再試行回数 — 保存が失敗するたびに残り再試行回数の値が1つ減り、文言が揺れる方式 — 計測 累積面積 1.646% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · 自動保存表示 9選 — 値が秒ごとに更新される9つの保存状態
このデモが属する記事
自動保存表示 9選 — 値が秒ごとに更新される9つの保存状態この自動保存表示の9選では、9つのうち1つは保存中という文言が点3つと共に繰り返され、1つは最終保存時刻の値が秒単位で更新されて何分前という表示に変わり、もう1つはオフライン時の保留件数の値がオンラインに戻った瞬間に0まで減っていきます。 この記事のデモをすべて見る


