07Error shake and retry that remembers where it broke
When the XHR fires error or abort, the bar shakes side to side while keeping the loaded byte count, and retry requests only the remaining bytes instead of starting over.
Best forUploads on flaky networks, admin retry 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">Sending</b>
<b class="es__label-err">Send failed</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="Retry upload">
<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 Error shake and retry that remembers where it broke — Measured area 2.978% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · File Upload Progress Bar Loader: 9 UI Demos
The post this demo lives in
File Upload Progress Bar Loader: 9 UI DemosA file upload progress bar. Drop a file and bytes-over-time become a spinning ring, a speed readout, and a checkmark at 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
Loaders09 Failure shake — an effect where the indicator briefly shakes side to side once the refresh reaches a failed statePull to Refresh CSS: 9 Loading Patterns
Loaders06 Corruption-retry count value — the retry-count value climbs by one and the progress value resets to the start whenever verification failsDownload Progress Bar: 9 Loading UI Patterns