08A Button That Fails, Decays and Retries
On failure the button and its ring sway side to side with a shrinking amplitude, the ring rewinds to the start, and the label then turns into retry.
Best forRecovering a dropped download
SHAKE_AMPSHAKE_DECAYREWIND_MS

<main class="stage">
<div class="rt is-demo" id="rtRoot">
<div class="rt__wrap" id="rtBar" role="progressbar" aria-labelledby="rtLabel" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%">
<svg class="rt__svg" viewBox="0 0 232 96" aria-hidden="true">
<rect class="rt__ring" id="rtRing" x="3" y="3" width="226" height="90" rx="16" pathLength="100"/>
</svg>
<button class="rt__btn" id="rtBtn" type="button">
<svg class="rt__icon" viewBox="0 0 24 24" aria-hidden="true"><path id="rtGlyph" d="M12 4v11M6.5 10.5 12 16l5.5-5.5"/></svg>
<span id="rtLabel">Download</span>
</button>
</div>
<p class="rt__meta"><span class="rt__tag">X</span> <b id="rtX">0.0</b> <span class="rt__tag">P</span> <b id="rtP">0</b></p>
<p class="rt__sr" id="rtStatus" role="status"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 흔들림 진폭은 지수 감쇠 함수로, 링 되감기는 보간 곡선으로 JS 가 계산한다. 실패색(--e)은 글자와 아이콘으로도 구분된다.
$rt-face: $subject-lilac;
$rt-fail: $error-soft;
@include stage($stage-ink);
.rt {
position: relative;
width: min(100%, 280px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.rt__wrap {
--e: 0;
position: relative;
width: 232px;
max-width: 100%;
aspect-ratio: 232 / 96;
}
.rt__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rt__ring {
fill: none;
stroke: color-mix(in srgb, #{$subject-mint}, #{$tick-down} calc(var(--e) * 100%));
stroke-width: 5;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
stroke-linecap: round;
}
.rt__btn {
position: absolute;
left: 7%;
right: 7%;
top: 21%;
bottom: 21%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-3;
border: 0;
border-radius: $r-control;
background: color-mix(in srgb, #{$rt-face}, #{$rt-fail} calc(var(--e) * 100%));
color: $subject-ink;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.rt__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 4px; }
.rt__icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.rt__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: $stage-paper;
}
.rt__tag { font-weight: 700; letter-spacing: .08em; }
.rt__meta b { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
.rt__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.rt__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rt {
position: relative;
width: min(100%, 280px);
display: grid;
gap: 12px;
justify-items: center;
}
.rt__wrap {
--e: 0;
position: relative;
width: 232px;
max-width: 100%;
aspect-ratio: 232/96;
}
.rt__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rt__ring {
fill: none;
stroke: color-mix(in srgb, #4cd4a6, #ff7a5e calc(var(--e) * 100%));
stroke-width: 5;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
stroke-linecap: round;
}
.rt__btn {
position: absolute;
left: 7%;
right: 7%;
top: 21%;
bottom: 21%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border: 0;
border-radius: 12px;
background: color-mix(in srgb, #b9a0ff, #ffe0d8 calc(var(--e) * 100%));
color: #17141a;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.rt__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 4px;
}
.rt__icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.rt__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: #ffffff;
}
.rt__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.rt__meta b {
min-width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.rt__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.rt__btn {
transition: none;
}
}(function () {
// 기법: Failed 순간부터 흔들림 x = SHAKE_AMP x e^(-SHAKE_DECAY x t) x sin(2 pi f t) 로 진폭이 지수로 줄고,
// 링은 실패한 길이에서 처음으로 REWIND_MS 동안 되감긴다. 두 번째 시도는 끝까지 간다.
var SHAKE_AMP = 14;
var SHAKE_DECAY = 5;
var REWIND_MS = 500;
var SHAKE_HZ = 4.5;
var FAIL_AT = 0.62;
var RUN_MS = 700;
var REWIND_DELAY = 150;
var START_MS = 200;
var CYCLE_MS = 2000;
var root = document.getElementById('rtRoot');
var bar = document.getElementById('rtBar');
var btn = document.getElementById('rtBtn');
var ring = document.getElementById('rtRing');
var labelEl = document.getElementById('rtLabel');
var glyph = document.getElementById('rtGlyph');
var xEl = document.getElementById('rtX');
var pEl = document.getElementById('rtP');
var statusEl = document.getElementById('rtStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var ARROW = 'M12 4v11M6.5 10.5 12 16l5.5-5.5';
var BANG = 'M12 5v9M12 19v.5';
var clock = 0;
var runStart = null;
var outcome = 'fail';
var attempts = 0;
var settled = null;
var lastNote = '';
var t0 = null;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function stopDemo() { root.classList.remove('is-demo'); }
function outQuad(t) { return 1 - (1 - t) * (1 - t); }
function inOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function busy(now) { return runStart !== null && now - runStart < RUN_MS + REWIND_DELAY + REWIND_MS; }
function frame(now) {
var s;
var res = outcome;
if (root.classList.contains('is-demo')) {
s = now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS;
res = 'fail';
} else {
s = runStart;
}
if (settled) return settled;
if (s === null) return { p: 0, x: 0, e: 0, label: 'Download', glyph: ARROW, note: '' };
var t = now - s;
if (t < RUN_MS) {
var u = outQuad(t / RUN_MS);
return { p: res === 'fail' ? FAIL_AT * u : u, x: 0, e: 0, label: 'Receiving', glyph: ARROW, note: '' };
}
if (res === 'ok') return { p: 1, x: 0, e: 0, label: 'Done', glyph: ARROW, note: 'Download complete' };
var tau = (t - RUN_MS) / 1000;
var rw = clamp((t - RUN_MS - REWIND_DELAY) / REWIND_MS, 0, 1);
return {
p: FAIL_AT * (1 - inOut(rw)),
x: SHAKE_AMP * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau),
e: 1,
label: rw >= 1 ? 'Retry' : 'Failed',
glyph: BANG,
note: 'The download was interrupted'
};
}
function paint(f) {
var pct = Math.round(f.p * 100);
ring.style.strokeDashoffset = String((100 - f.p * 100).toFixed(2));
ring.style.opacity = f.p < 0.004 ? '0' : '1';
bar.style.setProperty('--e', String(f.e));
bar.style.transform = 'translateX(' + f.x.toFixed(2) + 'px)';
labelEl.textContent = f.label;
glyph.setAttribute('d', f.glyph);
xEl.textContent = f.x.toFixed(1);
pEl.textContent = String(pct);
bar.setAttribute('aria-valuenow', String(pct));
bar.setAttribute('aria-valuetext', pct + '%');
if (f.note !== lastNote) { lastNote = f.note; statusEl.textContent = f.note; }
}
btn.addEventListener('click', function () {
stopDemo();
if (busy(clock)) return;
outcome = attempts % 2 === 0 ? 'fail' : 'ok';
attempts += 1;
if (reduce) {
settled = outcome === 'ok'
? { p: 1, x: 0, e: 0, label: 'Done', glyph: ARROW, note: 'Download complete' }
: { p: 0, x: 0, e: 1, label: 'Retry', glyph: BANG, note: 'The download was interrupted' };
paint(settled);
settled = null;
runStart = null;
return;
}
runStart = clock;
});
function tick(ts) {
if (t0 === null) t0 = ts;
clock = ts - t0;
paint(frame(clock));
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint(frame(0));
} else {
requestAnimationFrame(tick);
}
})();// RetryDecayButton — vanilla 판(08-dlbtn-error-retry-decay) 의 React 포트.
// 실패 순간부터 흔들림 진폭이 지수로 줄고, 링은 실패한 길이에서 처음으로 REWIND_MS 동안 되감긴다. 두 번째 시도는 끝까지 간다.
import { useEffect, useRef } from "react";
import styles from "./RetryDecayButton.module.scss";
const SHAKE_AMP = 14;
const SHAKE_DECAY = 5;
const REWIND_MS = 500;
const SHAKE_HZ = 4.5;
const FAIL_AT = 0.62;
const RUN_MS = 700;
const REWIND_DELAY = 150;
const START_MS = 200;
const CYCLE_MS = 2000;
const ARROW = "M12 4v11M6.5 10.5 12 16l5.5-5.5";
const BANG = "M12 5v9M12 19v.5";
type Frame = { p: number; x: number; e: number; label: string; glyph: string; note: string };
type Outcome = "fail" | "ok";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function outQuad(t: number): number {
return 1 - (1 - t) * (1 - t);
}
function inOut(t: number): number {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
export default function RetryDecayButton() {
const rootRef = useRef<HTMLDivElement | null>(null);
const barRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const ringRef = useRef<SVGRectElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const glyphRef = useRef<SVGPathElement | null>(null);
const xRef = useRef<HTMLElement | null>(null);
const pRef = useRef<HTMLElement | null>(null);
const statusRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const bar = barRef.current;
const btn = btnRef.current;
const ring = ringRef.current;
const labelEl = labelRef.current;
const glyph = glyphRef.current;
const xEl = xRef.current;
const pEl = pRef.current;
const statusEl = statusRef.current;
if (!bar || !btn || !ring || !labelEl || !glyph || !xEl || !pEl || !statusEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let clock = 0;
let runStart: number | null = null;
let outcome: Outcome = "fail";
let attempts = 0;
let lastNote = "";
let t0: number | null = null;
let raf = 0;
const busy = (now: number): boolean =>
runStart !== null && now - runStart < RUN_MS + REWIND_DELAY + REWIND_MS;
const frame = (now: number): Frame => {
let s: number | null;
let res: Outcome = outcome;
if (demo) {
s = now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS;
res = "fail";
} else {
s = runStart;
}
if (s === null) return { p: 0, x: 0, e: 0, label: "내려받기", glyph: ARROW, note: "" };
const t = now - s;
if (t < RUN_MS) {
const u = outQuad(t / RUN_MS);
return { p: res === "fail" ? FAIL_AT * u : u, x: 0, e: 0, label: "받는 중", glyph: ARROW, note: "" };
}
if (res === "ok") return { p: 1, x: 0, e: 0, label: "완료", glyph: ARROW, note: "받기 완료" };
const tau = (t - RUN_MS) / 1000;
const rw = clamp((t - RUN_MS - REWIND_DELAY) / REWIND_MS, 0, 1);
return {
p: FAIL_AT * (1 - inOut(rw)),
x: SHAKE_AMP * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau),
e: 1,
label: rw >= 1 ? "다시 시도" : "실패",
glyph: BANG,
note: "받기가 끊겼어요"
};
};
const paint = (f: Frame): void => {
const pct = Math.round(f.p * 100);
ring.style.strokeDashoffset = (100 - f.p * 100).toFixed(2);
ring.style.opacity = f.p < 0.004 ? "0" : "1";
bar.style.setProperty("--e", String(f.e));
bar.style.transform = `translateX(${f.x.toFixed(2)}px)`;
labelEl.textContent = f.label;
glyph.setAttribute("d", f.glyph);
xEl.textContent = f.x.toFixed(1);
pEl.textContent = String(pct);
bar.setAttribute("aria-valuenow", String(pct));
bar.setAttribute("aria-valuetext", pct + "%");
if (f.note !== lastNote) { lastNote = f.note; statusEl.textContent = f.note; }
};
const onClick = (): void => {
demo = false;
if (busy(clock)) return;
outcome = attempts % 2 === 0 ? "fail" : "ok";
attempts += 1;
if (reduce) {
paint(outcome === "ok"
? { p: 1, x: 0, e: 0, label: "완료", glyph: ARROW, note: "받기 완료" }
: { p: 0, x: 0, e: 1, label: "다시 시도", glyph: BANG, note: "받기가 끊겼어요" });
runStart = null;
return;
}
runStart = clock;
};
btn.addEventListener("click", onClick);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
clock = ts - t0;
paint(frame(clock));
raf = requestAnimationFrame(tick);
}
if (reduce) paint(frame(0));
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.rt} ref={rootRef}>
<div className={styles.rt__wrap} ref={barRef} role="progressbar" aria-labelledby="rtLabel" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} aria-valuetext="0%">
<svg className={styles.rt__svg} viewBox="0 0 232 96" aria-hidden="true">
<rect className={styles.rt__ring} ref={ringRef} x="3" y="3" width="226" height="90" rx="16" pathLength="100" />
</svg>
<button className={styles.rt__btn} type="button" ref={btnRef}>
<svg className={styles.rt__icon} viewBox="0 0 24 24" aria-hidden="true"><path d={ARROW} ref={glyphRef} /></svg>
<span id="rtLabel" ref={labelRef}>내려받기</span>
</button>
</div>
<p className={styles.rt__meta}>
<span className={styles.rt__tag}>X</span> <b ref={xRef}>0.0</b> <span className={styles.rt__tag}>P</span> <b ref={pRef}>0</b>
</p>
<p className={styles.rt__sr} role="status" ref={statusRef} />
</div>
</main>
);
}08 A Button That Fails, Decays and Retries — Measured area 10.896% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Download Button: 9 Demos With Springs and Arcs
The post this demo lives in
CSS Download Button: 9 Demos With Springs and ArcsA css download button that only swaps its label leaves a stiff arrow, steps in progress and no sign of where the file we See all demos in this post
If you like this effect - similar demos
Buttons08 A Button That Reports a Failed Send With a Fading Shake and Turns Into RetrySend Button Animation CSS: 9 Fold-and-Fly Buttons
Buttons07 Thrown-speed decay move — motion continues at the speed value captured at release, with that value shrinking over time until it stopsButton Press Animation UI: 9 Calculation Patterns
Buttons04 Liquid-metal Button That Merges and Spreads With a Pointer-distance ValueButton Click Effect CSS: 9 Canvas Pixel Patterns