GODRICH

9 button state css patterns, loading to done

A button state css pattern decides what a button looks like between the press and the result.

Auto-plays · click a tile to jump to its section · all nine in one zip

These nine are not ranked by popularity; they follow the order one button lives through. It gets pressed, waits for an answer, and returns (01); it explains why it cannot be pressed yet (02); it learns to stay pressed (03). The middle three are presses whose result stays on screen: as a number (04), as a few seconds of progress (05), and as something irreversible that asks you to confirm first (06). The last three are buttons that don't stand alone: one has another route bolted on beside it (07), one becomes one of three mutually exclusive slots (08), and one says what happened for a moment, then stops saying it (09). One rule runs through all nine. The current state lives in exactly one place — aria-busy, aria-pressed, aria-disabled, aria-checked, or aria-expanded, whichever fits — and the CSS reads that attribute. Keep it in two places and they will drift apart, and the half that drifts is always the half nobody can see.

01One round trip from loading to done

One press swaps the label for a spinner beside a "Saving" line, cuts to a check 1.2 seconds later, and returns to the original label two seconds after that. While it runs, the button is disabled, so it can't fire twice, and aria-busy says the work is in flight. A single function changes the state name, the ARIA flag, and the disabled flag together, so the visuals and the screen reader can never disagree.

aria-busysteps(1, end)setTimeout
function 상태(s) {
  btn.setAttribute('data-state', s);
  btn.setAttribute('aria-busy', String(s === 'busy'));
  btn.disabled = s !== 'idle';
}
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');
  clearTimeout(t1);
  clearTimeout(t2);
  상태('busy');
  t1 = setTimeout(function () { 상태('done'); }, 1200);
  t2 = setTimeout(function () { 상태('idle'); }, 3200);
});

02Button that unlocks when the form is valid

Until the field holds a real address, the button sits dimmed and locked, but because it uses aria-disabled instead of disabled, it still takes keyboard focus. Press it anyway and nothing happens except a small tooltip that says why, which stays for 1.4 seconds. A truly disabled button can't be focused at all, so there is never a moment when that explanation could be read out.

aria-disabledcheckValiditypointer-events
function 검사() {
  var ok = mail.checkValidity() && mail.value.length > 0;
  btn.setAttribute('aria-disabled', String(!ok));
  return ok;
}
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');
  if (!검사()) {
    btn.setAttribute('data-why', '1');
    setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
    return;
  }
  btn.setAttribute('data-sent', '1');
});

03One button holding on and off

Two faces sit stacked in the same spot inside the button, and a selector that reads aria-pressed decides which one shows. The script does exactly one thing — flip that attribute — so the fill, the icon, and the announced state all come from the same value. To make the bounce replay on back-to-back presses, remove the class and force a reflow before adding it again.

aria-pressedsteps(1, end)scale
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }

.tp__btn.is-pop {
  animation-name: tp-tap;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: 1;
}
@keyframes tp-tap {
  0%   { transform: scale(.94); }
  60%  { transform: scale(1.03); }
  100% { transform: scale(1); }
}

04Like button with a rolling count

The number window is pinned to the height of a single line, with the old and new values stacked inside it. A like doesn't rewrite textContent; it lifts the stacked column by 22px so the new value arrives from below. Digits have to keep a fixed width or the window jitters, which is what font-variant-numeric: tabular-nums is for.

aria-livetranslateYscale
.lk__win {
  display: block;
  overflow: hidden;
  height: 22px;
  width: 46px;
}
.lk__roll { display: block; transform: translateY(0); }
.lk__n { display: block; height: 22px; font-variant-numeric: tabular-nums; }
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }

05The button becomes its own progress bar

The percentage lives in one variable, and both the fill width and the printed number read it. Write it as a plain --p: 0 and the browser treats the value as text, so it jumps instead of flowing from 0 to 100. Register it as an integer with @property and it interpolates; feed the same variable to counter-reset and it prints.

@propertylinear-gradientaria-valuenow
@property --p {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.dl__fill {
  background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__pct::after {
  counter-reset: p var(--p);
  content: counter(p) "%";
  font-variant-numeric: tabular-nums;
}

06Delete that only fires on a long press

Hold the button and the ring around it draws one lap through stroke-dashoffset; only a finished lap deletes anything. Let go early and the same transition rewinds, so nothing happens. Without setPointerCapture on the press, sliding a finger off the button never delivers the cancel event, and the ring keeps filling on its own.

pointerdownstroke-dashoffsetsetPointerCapture
btn.addEventListener('pointerdown', function (e) {
  root.classList.remove('is-demo');
  root.setAttribute('data-state', 'idle');
  btn.setPointerCapture(e.pointerId);
  root.classList.add('is-holding');
  clearTimeout(끝);
  끝 = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
  btn.addEventListener(t, function () { clearTimeout(끝); root.classList.remove('is-holding'); });
});

07Primary action with other routes attached

It looks like one control, but there are two button elements inside one outline, and only the right-hand arrow half opens the list. The wide left half runs the most common action immediately and only ever closes the list, so it never sets aria-expanded to true. The list itself is a role="menu" whose rows are role="menuitem", which is what keeps the same path reachable from the keyboard.

aria-expandedaria-haspopuptranslateY
<div class="sp__group" role="group" aria-label="Send options">
  <button class="sp__main" id="sp-main" type="button">
    <span class="sp__t">Send now</span>
  </button>
  <button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="More ways to send">
    <span class="sp__caret" aria-hidden="true"></span>
  </button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
  <li role="none"><button class="sp__item" type="button" role="menuitem">Schedule send</button></li>
  <li role="none"><button class="sp__item" type="button" role="menuitem">Save to drafts</button></li>
  <li role="none"><button class="sp__item" type="button" role="menuitem">Preview first</button></li>
</ul>

08Pill that slides to the chosen slot

The three slots are flex: 0 0 auto and pinned to 80px, and the pill only ever moves by multiples of that number. Mix a fixed travel distance with flex: 1 1 0 and the rendered slot width no longer matches the distance you hard-coded, which lands the pill halfway onto the next label. The chosen slot is recorded in aria-checked, and the text color is read from that same attribute.

aria-checkedflex: 0 0 autotranslateX
.sg__pill {
  position: absolute;
  width: $칸;
  border-radius: $r-pill;
  transform: translateX(0);
}
.sg__seg {
  flex: 0 0 auto;
  width: $칸;
  height: 36px;
}
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($칸); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($칸 * 2); }

09Button that says copied for a moment

The label changes only once navigator.clipboard.writeText has settled, and it slips back 1.6 seconds later. Somewhere the permission will be blocked, and the user still deserves a reaction, so success and failure both run the same closing function. A label that changes for a moment is visual only, which is why the same words also go out through a role="status" line.

navigator.clipboardwriteTextaria-live
btn.addEventListener('click', function () {
  root.classList.remove('is-demo');
  var 끝내기 = function () {
    root.setAttribute('data-copied', '1');
    clearTimeout(되돌림);
    되돌림 = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
  };
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(src.textContent).then(끝내기, 끝내기);
  } else {
    끝내기();
  }
});

Where does it break?

Components that change state break at the poster image first. Every demo is sampled at 24 frames and the one that differs most from the frame before it becomes the poster, and the first cut of 07 opened its menu in the loop and then closed it again. A menu leaving the screen always changes more pixels than a menu arriving, so the poster came out as an orange field with a closed button sitting in it. The fix was to stop closing the menu in the loop at all: open it once at 8%, leave it open, drop the rows in with a staggered translateY, then walk a background color down them. That reads as 24.58% changed area over 12 of 23 frames, and the poster is now the open list.

The second trap is a fight between cuts and frame count. Cutting a state change with steps(1, end) kills the half-transparent ghost frame, but a cut only ever produces two frames of difference. So every one of the nine carries a small transform-driven motion alongside its cut: a spinner that keeps turning in 01, a scale bounce in 03, a ring that expands only at the moment of copying in 09. Item 09 also splits its return, cutting the icon at 76% and the color and label at 84%. The return has to change less than the arrival, or the poster lands on the wrong half of the story. The third trap is one that no amount of staring would catch. The loop in 02 reveals a fake typed address with steps(8, end), and at a width of 108px the last character was sliced in half and read [email protected]. Only at 120px did the address come out whole. The password for the archive with all nine sources is sqph35dy, and the numbers fixed here are the ones inside it.

The fourth trap is size. A 320×200 stage leaves 174px of vertical room once padding is gone, and the menu in 07 is absolutely positioned, so it never counts toward its parent's height. The stage looks fine while the document height quietly grows and the grid cell sprouts a scrollbar, which is why the wrapper reserves padding-bottom equal to the height of the open list. On narrow screens that reservation drops from 116px to 100px. Values inside a narrow-screen media query get rounded down, never up.

Accessibility and reduced-motion

With prefers-reduced-motion: reduce on, all nine stop the autoplay loop and leave the current state exactly where it is. The spinner in 01 stops turning, the fill in 05 stands empty, and the ring in 06 arrives full in one step instead of growing. Nothing in the attribute layer is switched off on either path.

Item Mouse Keyboard Where the state is written
01 Async cycle Click Enter · Space aria-busy · disabled
02 Enable gate Click Tab keeps focus aria-disabled · aria-describedby
03 Toggle Click Enter · Space aria-pressed
04 Like Click Enter · Space aria-pressed · aria-live
05 Progress Click Enter · Space role="progressbar" · aria-valuenow
06 Hold Press and hold Enter · Space role="status"
07 Split Click either half Tab · Escape aria-expanded · aria-haspopup
08 Segments Click ← → arrows role="radio" · aria-checked
09 Copy Click Enter · Space aria-live="polite"

Why a pressed state belongs in an attribute rather than a class is spelled out in the MDN page on aria-pressed. The registration that 05 depends on is documented in the MDN page on @property, including the detail that leaving out syntax throws the whole registration away. Components that start because someone pressed them live in the click category, and buttons meant to be the one thing pressed on a page live in the CTA category.

FAQ

Should a locked button use disabled or aria-disabled?

Use aria-disabled when you have a reason to give, and disabled when you don't. A real disabled button can't receive focus, so the keyboard never reaches it and the explanation wired up through aria-describedby is never announced. The trade-off is that aria-disabled doesn't stop the click for you, so the handler has to return early on its own.

How long should the finished state stay up?

Two seconds is enough. Item 01 answers in 1.2 seconds, holds the check for two, and then returns; any shorter and the result gets missed, any longer and the next action feels blocked. When the response time is unknown, switch to the finished state the moment the response lands and put only the finished display on a fixed timer.

Can all nine live in one project?

They can, because each item carries its own class prefix. The one shared name is @property --p in 05, which is registered document-wide, so a second registration elsewhere with a different syntax wins simply by coming later. If you're planning several progress buttons, rename it to something like --dl-p so the registration belongs to the component.

Enter the archive password

The password is inside this article. You will find it as you read.