Every control shows its state
Hover, press, focus, disabled and loading, all without changing size.
From refactoring-ui, after Refactoring UI by Adam Wathan and Steve Schoger.
Five states, one button
Hover, press and Tab to Send, then click it fast.
Browser defaultsBad
INV-1042Draft
Acme Corp · $4,280.00
- hover
- active
- focus-visible
- loading
outline: noneEvery state visibleGood
INV-1042Draft
Acme Corp · $4,280.00
Unlocks after sending.
- hover
- active
- focus-visible
- loading
:active scale(.97):focus-visible outlinearia-busy.btn { display: inline-grid; }
.btn > * { grid-area: 1 / 1; }
.btn:active { transform: scale(.97); }
.btn:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn[aria-busy="true"] .label { opacity: 0; }
Keyboard-only walk
Click Email, then press Tab and Shift+Tab.
outline: noneBadnothing:focus-visible ringGoodnothing:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
Delay the spinner
Save both at Fast, then at Slow.
Instant spinnerBad
delay: 0 msDelayed spinnerGood
delay: 400 msconst t = setTimeout(() => (button.dataset.spinner = ""), 400);
try { await work(); } finally { clearTimeout(t); delete button.dataset.spinner; }
Why it matters, pros and cons
Why it matters
Every input needs an answer: seen, got it, working, or not now. Without one, people double-click or lose their place.
Pros
- Press feedback makes the UI feel fast.
- A busy state blocks double submits.
- Fixed-size loading causes no layout shift.
Cons
- Five states per control is real work.
- Hover does not exist on touch screens.
- Disabled buttons need a reason shown nearby.