07途切れた地点を覚えるエラーシェイクとリトライ
XHRがerror·abortを投げると、その時点のloaded値を残したままバーが左右に揺れ、リトライは最初からではなく残りバイトだけを再要求します。
使いどころ不安定なネットワークでのアップロード、管理者リトライUI
ProgressEventAbortControllerkeyframes

<main class="stage">
<div class="es is-demo" id="esRoot">
<div class="es__row">
<span class="es__label" id="esLabel">
<b class="es__label-run">送信中</b>
<b class="es__label-err">送信失敗</b>
</span>
<span class="es__warn" aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M236 128a108 108 0 1 1-108-108a108 108 0 0 1 108 108" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
</span>
<button class="es__retry" id="esRetry" type="button" aria-label="アップロード再試行">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M197.67 186.37a94.06 94.06 0 0 1-66.79 27.66a93 93 0 0 1-11-.65a94.19 94.19 0 0 1-82.14-71.71a8 8 0 0 1 15.53-3.8a78.19 78.19 0 0 0 68.17 59.63a78 78 0 0 0 64.16-26.4a8 8 0 0 1 12.07 10.5Zm38.24-66.14a8 8 0 0 0-9.6 6.06a78 78 0 0 1-27.68 41.72a8 8 0 1 0 9.66 12.76a94.06 94.06 0 0 0 33.31-50.24a94.19 94.19 0 0 0-52.71-98.86a8 8 0 0 0-6.9 14.44a78 78 0 0 1 44 66.6a94.6 94.6 0 0 0-9 7.52Z" opacity=".2"/><path d="M136 24a103.8 103.8 0 0 0-73 30a8 8 0 0 0 11.3 11.32A88 88 0 1 1 63.06 190a8 8 0 0 0-12.12 10.44A104 104 0 1 0 136 24m59.32 55.72a8 8 0 0 0-11.32 11.32L192 99H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V59a8 8 0 0 0-16 0v10.34Z"/></g></svg>
</button>
</div>
<div class="es__track">
<span class="es__fill" id="esFill"></span>
</div>
<div class="es__bar">
<span class="es__meta">ProgressEvent · AbortController</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 실패 흔들림과 재시도(07) — 흔들림은 translateX keyframes 뿐이다. 실패 시점의 채움 폭은
// 그대로 유지되고(scaleX 를 0 으로 되돌리지 않는다), 재시도는 그 폭에서 이어서 채워진다.
@include stage($stage-orange);
$es-text: $subject-ink;
$es-fill: $subject-ink;
$es-warn: $error;
$duration: $dur-loop;
$easing: $ease-out;
$color: $es-fill;
.es {
display: flex; flex-direction: column; gap: $sp-3;
width: 100%; height: 100%; min-height: 164px;
justify-content: center;
}
.es:focus-visible { outline: 2px solid $es-text; outline-offset: 3px; border-radius: $r-control; }
.es__row { display: flex; align-items: center; gap: $sp-2; }
.es__label {
position: relative;
flex: 1;
height: 14px;
font-weight: 600; font-size: 11px; line-height: 1.3;
}
.es__label b {
position: absolute; inset: 0;
font-weight: 600;
color: rgba($es-text, .82);
opacity: 0;
}
.es__label-run { opacity: 1 !important; }
.es.is-error .es__label-run { opacity: 0 !important; }
.es.is-error .es__label-err { opacity: 1 !important; color: $es-warn !important; }
.es__warn {
display: flex;
color: $es-warn;
opacity: 0;
transform: scale(.6);
}
.es.is-error .es__warn { opacity: 1; transform: scale(1); transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-elastic; }
.es__retry {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px;
border: 0; border-radius: $r-pill;
background: rgba($es-text, .12);
color: $es-text;
opacity: 0;
transform: scale(.6);
cursor: pointer;
padding: 0;
}
.es.is-error .es__retry { opacity: 1; transform: scale(1); transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-elastic; }
.es__track {
position: relative;
height: $sp-2;
border-radius: $r-pill;
background: rgba($es-text, .14);
overflow: hidden;
}
.es__fill {
position: absolute; inset: 0;
transform-origin: left center;
transform: scaleX(0);
border-radius: $r-pill;
background: $es-fill;
}
.es.is-error .es__track { animation: esShake $dur-enter $ease-out 1; }
.es__bar { display: flex; justify-content: flex-end; }
.es__meta { font-weight: 500; font-size: 10px; color: rgba($es-text, .6); }
@keyframes esShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(2px); }
}
// ── 데모 루프 — 58% 까지 채우고 실패, 흔들리고, 남은 폭에서 이어 채워 완료한다 ──
@keyframes esFillLoop {
0% { transform: scaleX(0); }
30% { transform: scaleX(.58); }
46% { transform: scaleX(.58); }
74% { transform: scaleX(1); }
86% { transform: scaleX(1); }
92%, 100% { transform: scaleX(0); }
}
@keyframes esTrackShake {
0%, 30% { transform: translateX(0); }
34% { transform: translateX(-6px); }
38% { transform: translateX(5px); }
41% { transform: translateX(-3px); }
44%, 100% { transform: translateX(0); }
}
@keyframes esWarnLoop {
0%, 31% { opacity: 0; transform: scale(.6); }
36% { opacity: 1; transform: scale(1); animation-timing-function: $ease-elastic; }
46% { opacity: 1; transform: scale(1); }
50%, 100% { opacity: 0; transform: scale(.6); }
}
@keyframes esLabelSwap {
0%, 30% { opacity: 1; }
31%, 49% { opacity: 0; }
50%, 100% { opacity: 1; }
}
@keyframes esLabelErrSwap {
0%, 30% { opacity: 0; }
31%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.is-demo .es__fill { animation: esFillLoop $duration $easing infinite; }
.is-demo .es__track { animation: esTrackShake $duration linear infinite; }
.is-demo .es__warn, .is-demo .es__retry { animation: esWarnLoop $duration linear infinite; }
.is-demo .es__label-run { animation: esLabelSwap $duration linear infinite; }
.is-demo .es__label-err { animation: esLabelErrSwap $duration linear infinite; color: $es-warn; }
@media (max-width: 340px) {
.es__retry { width: 20px; height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .es__fill, .is-demo .es__track, .is-demo .es__warn, .is-demo .es__retry,
.is-demo .es__label-run, .is-demo .es__label-err { animation: none !important; }
.is-demo .es__fill { transform: scaleX(.58) !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);
}
.es {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 164px;
justify-content: center;
}
.es:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
border-radius: 12px;
}
.es__row {
display: flex;
align-items: center;
gap: 8px;
}
.es__label {
position: relative;
flex: 1;
height: 14px;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
}
.es__label b {
position: absolute;
inset: 0;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
opacity: 0;
}
.es__label-run {
opacity: 1 !important;
}
.es.is-error .es__label-run {
opacity: 0 !important;
}
.es.is-error .es__label-err {
opacity: 1 !important;
color: #c2361a !important;
}
.es__warn {
display: flex;
color: #c2361a;
opacity: 0;
transform: scale(0.6);
}
.es.is-error .es__warn {
opacity: 1;
transform: scale(1);
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.es__retry {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
opacity: 0;
transform: scale(0.6);
cursor: pointer;
padding: 0;
}
.es.is-error .es__retry {
opacity: 1;
transform: scale(1);
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.es__track {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
overflow: hidden;
}
.es__fill {
position: absolute;
inset: 0;
transform-origin: left center;
transform: scaleX(0);
border-radius: 999px;
background: #17141a;
}
.es.is-error .es__track {
animation: esShake 600ms ease-out 1;
}
.es__bar {
display: flex;
justify-content: flex-end;
}
.es__meta {
font-weight: 500;
font-size: 10px;
color: rgba(23, 20, 26, 0.6);
}
@keyframes esShake {
0%, 100% {
transform: translateX(0);
}
20% {
transform: translateX(-6px);
}
40% {
transform: translateX(5px);
}
60% {
transform: translateX(-3px);
}
80% {
transform: translateX(2px);
}
}
@keyframes esFillLoop {
0% {
transform: scaleX(0);
}
30% {
transform: scaleX(0.58);
}
46% {
transform: scaleX(0.58);
}
74% {
transform: scaleX(1);
}
86% {
transform: scaleX(1);
}
92%, 100% {
transform: scaleX(0);
}
}
@keyframes esTrackShake {
0%, 30% {
transform: translateX(0);
}
34% {
transform: translateX(-6px);
}
38% {
transform: translateX(5px);
}
41% {
transform: translateX(-3px);
}
44%, 100% {
transform: translateX(0);
}
}
@keyframes esWarnLoop {
0%, 31% {
opacity: 0;
transform: scale(0.6);
}
36% {
opacity: 1;
transform: scale(1);
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
46% {
opacity: 1;
transform: scale(1);
}
50%, 100% {
opacity: 0;
transform: scale(0.6);
}
}
@keyframes esLabelSwap {
0%, 30% {
opacity: 1;
}
31%, 49% {
opacity: 0;
}
50%, 100% {
opacity: 1;
}
}
@keyframes esLabelErrSwap {
0%, 30% {
opacity: 0;
}
31%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.is-demo .es__fill {
animation: esFillLoop 2s ease-out infinite;
}
.is-demo .es__track {
animation: esTrackShake 2s linear infinite;
}
.is-demo .es__warn, .is-demo .es__retry {
animation: esWarnLoop 2s linear infinite;
}
.is-demo .es__label-run {
animation: esLabelSwap 2s linear infinite;
}
.is-demo .es__label-err {
animation: esLabelErrSwap 2s linear infinite;
color: #c2361a;
}
@media (max-width: 340px) {
.es__retry {
width: 20px;
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .es__fill, .is-demo .es__track, .is-demo .es__warn, .is-demo .es__retry,
.is-demo .es__label-run, .is-demo .es__label-err {
animation: none !important;
}
.is-demo .es__fill {
transform: scaleX(0.58) !important;
}
}// 실패 흔들림과 재시도 — error 이벤트가 오면 그 시점의 loaded 값을 남긴 채 바가 흔들리고,
// 재시도는 AbortController 로 이전 시도를 끊은 뒤 처음부터가 아니라 남은 바이트만 이어 보낸다.
(function () {
var SEED = 0x44c1e0;
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = mulberry32(SEED);
var FAIL_AT = 58;
var root = document.getElementById('esRoot');
var fill = document.getElementById('esFill');
var label = document.getElementById('esLabel');
var retry = document.getElementById('esRetry');
if (!root || !fill || !label || !retry) return;
var bus = new EventTarget();
var loaded = 0, failedOnce = false, controller = new AbortController(), timer = null;
bus.addEventListener('progress', function (e) {
fill.style.transform = 'scaleX(' + (e.loaded / 100) + ')';
});
bus.addEventListener('error', function (e) {
root.classList.add('is-error');
});
function run(startAt) {
loaded = startAt;
controller = new AbortController();
timer = setInterval(function () {
if (controller.signal.aborted) return;
loaded = Math.min(100, loaded + 4 + rand() * 8);
if (!failedOnce && loaded >= FAIL_AT) {
failedOnce = true;
clearInterval(timer);
bus.dispatchEvent(new ProgressEvent('error', { lengthComputable: true, loaded: loaded, total: 100 }));
return;
}
bus.dispatchEvent(new ProgressEvent('progress', { lengthComputable: true, loaded: loaded, total: 100 }));
if (loaded >= 100) clearInterval(timer);
}, 220);
}
retry.addEventListener('click', function () {
controller.abort();
root.classList.remove('is-error');
run(loaded);
});
function live() {
if (!root.classList.contains('is-demo')) return;
root.classList.remove('is-demo');
run(0);
}
['pointerdown', 'focusin'].forEach(function (ev) { root.addEventListener(ev, live); });
})();// UploadProgressErrorShakeRetry — vanilla 판(run/329/demo/07-upload-progress-error-shake-retry/index.html) 의 React 포트.
// error 이벤트가 오면 그 시점의 loaded 값을 남긴 채 바가 흔들리고, 재시도는 AbortController 로
// 이전 시도를 끊은 뒤 처음부터가 아니라 남은 바이트만 이어 보낸다(scaleX 를 0 으로 되돌리지 않는다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UploadProgressErrorShakeRetry.module.scss";
export type UploadProgressErrorShakeRetryProps = {
duration?: string;
easing?: string;
color?: string;
seed?: number; // mulberry32 시드
failAt?: number; // 이 loaded 값(%) 에서 한 번 실패한다
stepBase?: number; // 매 틱 기본 증가분(퍼센트 포인트)
stepJitter?: number; // 매 틱 랜덤 증가분 상한
tickMs?: number; // 진행 틱 주기(ms)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const LABEL_RUN = "전송 중";
const LABEL_ERR = "전송 실패";
const RETRY_LABEL = "업로드 재시도";
const META = "ProgressEvent · AbortController";
const SEED = 0x44c1e0;
const FAIL_AT = 58;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
export default function UploadProgressErrorShakeRetry({
duration = "2s",
easing = "ease-out",
color = "#17141a",
seed = SEED,
failAt = FAIL_AT,
stepBase = 4,
stepJitter = 8,
tickMs = 220
}: UploadProgressErrorShakeRetryProps) {
const [demo, setDemo] = useState(true);
const [isError, setIsError] = useState(false);
const fillRef = useRef<HTMLSpanElement | null>(null);
const randRef = useRef<(() => number) | null>(null);
const loadedRef = useRef(0) as Box<number>;
const failedOnceRef = useRef(false) as Box<boolean>;
const timerRef = useRef(0) as Box<number>;
const controllerRef = useRef<AbortController | null>(null);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
useEffect(() => {
return () => {
window.clearInterval(timerRef.current);
if (controllerRef.current) controllerRef.current.abort();
};
}, []);
function paint(loaded: number): void {
if (fillRef.current) fillRef.current.style.transform = "scaleX(" + (loaded / 100) + ")";
}
function run(startAt: number): void {
loadedRef.current = startAt;
const controller = new AbortController();
controllerRef.current = controller;
const rand = randRef.current!;
timerRef.current = window.setInterval(() => {
if (controller.signal.aborted) return;
loadedRef.current = Math.min(100, loadedRef.current + stepBase + rand() * stepJitter);
if (!failedOnceRef.current && loadedRef.current >= failAt) {
failedOnceRef.current = true;
window.clearInterval(timerRef.current);
setIsError(true);
return;
}
paint(loadedRef.current);
if (loadedRef.current >= 100) window.clearInterval(timerRef.current);
}, tickMs);
}
function onRetry(): void {
if (controllerRef.current) controllerRef.current.abort();
setIsError(false);
run(loadedRef.current);
}
function live(): void {
if (!demo) return;
setDemo(false);
randRef.current = mulberry32(seed);
failedOnceRef.current = false;
run(0);
}
const rootCls = [styles.es, demo ? styles.isDemo : "", isError ? styles.isError : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div
className={rootCls}
style={vars}
role="group"
tabIndex={0}
aria-label="파일 업로드 전송 실패와 재시도"
onPointerDown={live}
onFocus={live}
>
<div className={styles.row}>
<span className={styles.label}>
<b className={styles.labelRun}>{LABEL_RUN}</b>
<b className={styles.labelErr}>{LABEL_ERR}</b>
</span>
<span className={styles.warn} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M236 128a108 108 0 1 1-108-108a108 108 0 0 1 108 108" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
</span>
<button
className={styles.retry}
type="button"
aria-label={RETRY_LABEL}
onClick={onRetry}
>
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M197.67 186.37a94.06 94.06 0 0 1-66.79 27.66a93 93 0 0 1-11-.65a94.19 94.19 0 0 1-82.14-71.71a8 8 0 0 1 15.53-3.8a78.19 78.19 0 0 0 68.17 59.63a78 78 0 0 0 64.16-26.4a8 8 0 0 1 12.07 10.5Zm38.24-66.14a8 8 0 0 0-9.6 6.06a78 78 0 0 1-27.68 41.72a8 8 0 1 0 9.66 12.76a94.06 94.06 0 0 0 33.31-50.24a94.19 94.19 0 0 0-52.71-98.86a8 8 0 0 0-6.9 14.44a78 78 0 0 1 44 66.6a94.6 94.6 0 0 0-9 7.52Z" opacity=".2"/><path d="M136 24a103.8 103.8 0 0 0-73 30a8 8 0 0 0 11.3 11.32A88 88 0 1 1 63.06 190a8 8 0 0 0-12.12 10.44A104 104 0 1 0 136 24m59.32 55.72a8 8 0 0 0-11.32 11.32L192 99H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V59a8 8 0 0 0-16 0v10.34Z"/></g></svg>
</button>
</div>
<div className={styles.track}>
<span className={styles.fill} ref={fillRef}></span>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META}</span>
</div>
</div>
</main>
);
}07 途切れた地点を覚えるエラーシェイクとリトライ — 計測 累積面積 2.978% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · アップロード プログレスバー ローダー9種 — 計算するUI
このデモが属する記事
アップロード プログレスバー ローダー9種 — 計算するUIアップロード プログレスバーは、バイトと時間の実測値からリング・速度・チェックの動きを計算して描く部品です。ファイルをドロップすると、バイトと時間から速度を計算してリングが回り、完了するとチェックに変わります。9つの状態をライブラリなしで計 この記事のデモをすべて見る


