Interaction
Guarded control
A control blocked by a precondition someone can fix stays in the tab order, carries the reason as real text, and refuses the action in its handler — because the native disabled attribute takes the control and its explanation out of reach together.
Reach for it when building
- a save button waiting on a required field
- an action that needs a higher permission level
- a submit blocked until a file finishes uploading
- a feature that needs a setting turned on first
- a schedule button waiting on a reading or measurement
- an export gated behind a plan or quota
- a publish button waiting on a review
- aria-disabled
- disabled
- preconditions
- keyboard
- aria-describedby
- forms
<div class="gc-demo">
<div class="gc-grid">
<section class="gc-cell">
<h3 class="gc-h">The native attribute</h3>
<button type="button" class="gc-btn" disabled>Schedule service</button>
<p class="gc-why">Add a mileage reading first — the interval is computed from it.</p>
<p class="gc-verdict gc-bad">Skipped by Tab. The reason beside it is never announced.</p>
</section>
<section class="gc-cell">
<h3 class="gc-h">The guarded control</h3>
<button type="button" class="gc-btn" id="gc-guarded" aria-disabled="true" aria-describedby="gc-reason">Schedule service</button>
<p class="gc-why" id="gc-reason">Add a mileage reading first — the interval is computed from it.</p>
<p class="gc-verdict gc-good">Focusable, announced, and it says what to do.</p>
</section>
</div>
<p class="gc-hint">Tab through both cells. Only one of them stops on the button.</p>
<div class="gc-fix">
<label class="gc-field">
<span>Odometer</span>
<input type="number" id="gc-miles" inputmode="numeric" placeholder="e.g. 48200" />
</label>
<button type="button" class="gc-btn gc-busy-btn" id="gc-busy">Submit reading</button>
</div>
<p class="gc-log" id="gc-log" role="status"></p>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.gc-demo { display: flex; flex-direction: column; gap: 1rem; max-width: 620px; }
.gc-grid { display: grid; gap: 0.75rem; }
@media (min-width: 34rem) {
.gc-grid { grid-template-columns: 1fr 1fr; }
}
.gc-cell {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.gc-h { margin: 0; font-size: 0.9375rem; color: var(--dim); font-weight: 600; }
.gc-btn {
align-self: flex-start;
font: inherit;
font-weight: 600;
min-height: 44px;
padding: 0.55rem 1rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-ink);
cursor: pointer;
}
/* The guarded state keeps text contrast: it is styled by attribute, and the
attribute does not carry the browser's own dimming. */
.gc-btn[aria-disabled='true'] {
background: var(--surface);
color: var(--ink);
border-style: dashed;
border-color: var(--border);
cursor: not-allowed;
}
/* The native attribute, for comparison. Nothing here can bring it back into the
tab order. */
.gc-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.gc-why { margin: 0; font-size: 0.9375rem; color: var(--dim); }
.gc-verdict { margin: 0; font-size: 0.9375rem; font-weight: 600; }
.gc-bad { color: var(--bad); }
.gc-good { color: var(--ok); }
.gc-hint { margin: 0; font-size: 0.9375rem; color: var(--dim); font-style: italic; }
.gc-fix { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
.gc-field { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9375rem; color: var(--dim); }
.gc-field input {
font: inherit;
min-height: 44px;
padding: 0.45rem 0.6rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink);
width: 11rem;
}
/* Busy is the one state the native attribute is right for: it is not a
precondition anybody can act on, and it lasts a moment. */
.gc-busy-btn[aria-busy='true'] { background: var(--surface-2); color: var(--dim); border-color: var(--border); }
.gc-log { margin: 0; min-height: 1.6em; font-size: 0.9375rem; font-weight: 600; }
.gc-log[data-tone='blocked'] { color: var(--bad); }
.gc-log[data-tone='done'] { color: var(--ok); }const guarded = document.getElementById('gc-guarded');
const reason = document.getElementById('gc-reason');
const miles = document.getElementById('gc-miles');
const busyButton = document.getElementById('gc-busy');
const log = document.getElementById('gc-log');
function report(message, tone) {
log.textContent = message;
log.dataset.tone = tone;
}
/* The guard lives in the handler. The attribute is a signal to assistive tech
and to CSS; it is not what stops the action. */
guarded.addEventListener('click', () => {
if (guarded.getAttribute('aria-disabled') === 'true') {
report('Blocked: ' + reason.textContent, 'blocked');
miles.focus();
return;
}
report('Service scheduled for 52,000 miles.', 'done');
});
busyButton.addEventListener('click', () => {
const value = Number(miles.value);
if (!Number.isFinite(value) || value <= 0) {
report('Enter a reading above zero.', 'blocked');
miles.focus();
return;
}
/* Transient work is the exception: the control is genuinely inert for a
moment, nobody can act on it, so the native attribute is correct here. */
busyButton.disabled = true;
busyButton.setAttribute('aria-busy', 'true');
busyButton.textContent = 'Saving…';
window.setTimeout(() => {
busyButton.disabled = false;
busyButton.removeAttribute('aria-busy');
busyButton.textContent = 'Submit reading';
guarded.setAttribute('aria-disabled', 'false');
reason.textContent = 'Reading recorded at ' + value.toLocaleString() + ' miles.';
report('Reading saved. The schedule button is live now.', 'done');
}, 900);
});Paste this into an agent to rebuild the pattern from scratch.
Build a control that is unavailable rather than disabled. It stays in the tab order, it carries the reason as real text next to it, and the action is refused inside the handler.
Reach for this whenever the block is a precondition somebody can act on: a required field still empty, a permission they do not hold, a setting not turned on, a measurement not taken yet. Walk away for transient busy states — a request in flight, a file still uploading. Nobody can act on those, they last a moment, and the native `disabled` attribute is exactly right there because blocking a double submit is the whole job.
The rule that makes it work: **set `aria-disabled="true"`, never the `disabled` attribute.** A natively disabled control leaves the tab order and is exempt from contrast requirements. That means the button *and* the sentence explaining it both become unreachable — for exactly the people who needed the explanation most. Someone navigating by keyboard tabs straight past the thing they are stuck on and never learns why.
The reason is required, not optional. Treat it as a required parameter of the component, so a guarded control cannot be built without one. "Save" greyed out with nothing beside it sends people to support; "Save — add a billing address first" sends them to the billing form. Wire it with `aria-describedby` so it is announced with the button rather than found by luck.
Style the state by attribute, not by the browser's default dimming. `[aria-disabled="true"]` gets its own rule — a dashed border, a flat fill, a not-allowed cursor — and the label keeps full text contrast. The 4.5:1 floor still applies here; the exemption people rely on belongs to the native attribute they are no longer using.
Refuse the action in the handler as the first statement, and return early. The attribute is a signal to assistive tech and to CSS, and nothing else. A control that looks blocked but still runs its handler is worse than one that was never guarded.
When the click is refused, say so and point at the fix: move focus to the field that would unblock it, and announce the reason through a `role="status"` region. A silent refusal is indistinguishable from a broken button.
Every colour comes from theme custom properties, and the guarded state has to stay legible in both themes — a fill that reads as "off" in light mode often reads as "missing" in dark.