Accordion expandWeb · App
Target
Implement the "Button loading spinner" effect in vanilla HTML/CSS/JS. Context: On press the label fades out and an inline spinner takes its place; the button keeps its exact size. Trigger: the element is pressed (pointerdown → pointerup). Timeline: time-based (duration + easing). Motion tokens (normal): - opacity (label): 1 → 0 - scale (label): 1 → 0.9 - opacity (spinner): 0 → 1 - rotate (spinner): 0deg → 360deg, infinite - duration: 180ms (swap) · 800ms per spinner turn - easing: ease-out (swap) · linear (spin) Motion tokens (prefers-reduced-motion): - opacity (label): 1 → 0 - opacity (spinner): 0 → 1 - duration: 180ms - easing: ease-out - note: spinner still rotates (it conveys progress) but at 1.6s per turn Technique: prefer pure CSS transitions/animations; use JS only to toggle classes. Acceptance: - animate only transform and opacity unless the spec lists other properties - no layout shift; content is visible without the animation - when prefers-reduced-motion is set, use the reduced tokens only - button width and height do not change between states - set aria-busy=true and disabled while loading
Generated from the spec. Prompts differ by target name only; the tokens are the source of truth.
demo.html
<div class="fx-button-loading-state" data-fx-root> <button type="button" class="fx-btn"><span class="fx-label">Save changes</span><span class="fx-spin" aria-hidden="true"></span></button> </div>
demo.css
.fx-button-loading-state { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.fx-button-loading-state .fx-btn { position: relative; display: grid; place-items: center; height: 44px; padding: 0 22px; border: 0; border-radius: 10px; background: var(--fx-accent, #4f46e5); color: #fff; font: 500 14px/1 Inter, system-ui, sans-serif; cursor: pointer; }
.fx-button-loading-state .fx-label, .fx-button-loading-state .fx-spin { grid-area: 1 / 1; transition: opacity 180ms ease-out, transform 180ms ease-out; }
.fx-button-loading-state .fx-spin { width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; opacity: 0; }
.fx-button-loading-state .fx-btn[aria-busy="true"] { cursor: progress; }
.fx-button-loading-state .fx-btn[aria-busy="true"] .fx-label { opacity: 0; transform: scale(.9); }
.fx-button-loading-state .fx-btn[aria-busy="true"] .fx-spin { opacity: 1; animation: fx-bls-spin 800ms linear infinite; }
@keyframes fx-bls-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.fx-button-loading-state .fx-btn[aria-busy="true"] .fx-label { transform: none; }
.fx-button-loading-state .fx-btn[aria-busy="true"] .fx-spin { animation-duration: 1.6s; }
}
demo.js
export default function mount(root) {
const btn = root.querySelector('.fx-btn');
let t;
const start = () => { clearTimeout(t); btn.setAttribute('aria-busy', 'true'); btn.disabled = true; t = setTimeout(stop, 1800); };
const stop = () => { clearTimeout(t); btn.removeAttribute('aria-busy'); btn.disabled = false; };
btn.addEventListener('click', start);
return { play() { stop(); setTimeout(start, 250); }, stop };
}
Inspired by developer.apple.com — indeterminate progress on a control. The preview is an original re-creation; the effect name belongs to its author.
Label and spinner share one grid cell, so swapping them never changes the box. The spinner is a bordered circle (one element, transform-only rotation) instead of an SVG, which keeps the markup copy-pasteable.
Added 2026-11-09 · Report an issue