# Disabled buttons that can't be found or say why

Source: https://easeweb.dev/learn/disabled-controls
Topics: Buttons and links > Disabled controls can be found and say why they are disabled (WCAG 4.1.2)
The fix: Either leave the button enabled and, when it is pressed too early, show what is missing beside its cause, or mark it aria-disabled="true" so it stays focusable and link a visible reason to it with aria-describedby. Native disabled with a visible reason passes 4.1.2 too, but Tab still skips the button and its reason.
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/disabled https://design-system.service.gov.uk/components/button/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A checkout ends with a box to tick, "I accept the terms", and a Place order button below it. Until the box is ticked, the button is grey. A sighted mouse user may guess that the box is holding it back. Others can't.

On one site the button is grey only because of a class. A screen reader user tabs to it and hears "Place order, button", which sounds ready. A click does nothing, because the class turns pointer events off, but Enter still submits the form, and the order comes back with an error at the top of the page. On another site the button has the `disabled` attribute. A keyboard user tabs from the checkbox straight to the "Delivery and returns" link below, and never lands on the button at all. A screen reader user moving with Tab never hears that there is a button, and nothing on the page says what is missing.

The same thing happens with any action that waits for something: Next on a step that isn't complete, Save on a form with no changes, Add to basket before a size is chosen, Send on an empty message.

## Why it fails

Success criterion 4.1.2 requires that the state of a user interface component can be determined by software. Unavailable is a state. When a button is drawn as unavailable but its code says nothing, assistive technology announces it as an ordinary button, and the keyboard still runs it. That is the failure the criterion names.

A button with the `disabled` attribute exposes its state, so it passes 4.1.2. It still leaves people stuck. Browsers take it out of the Tab order and draw it faint, and WCAG sets no contrast for disabled controls, so a person may never find the button or read its label. The topic asks for more than the criterion: that the control can be found, and that it says why it is disabled.

## Who is affected

The barrier comes at the end of a task, after the person has filled in everything else. A long form on a phone hides the checkbox above the fold, so even a sighted person may see only the grey button.

## What automation and AI miss

Axe and similar tools check that a button has a name and a role. A button greyed out by a class has both, so it passes. A disabled button is skipped by contrast checks on purpose, and nothing asks whether a reason is shown. Code assistants usually reach for the `disabled` attribute, because it fixes the state, and leave the button unreachable with no reason. When they use `aria-disabled`, they often forget to block the action, so the button announces "dimmed" and still submits. Tab through the form, then press the button before the form is ready, and see what you are told.

## Before: a button greyed out by a class

```html
<label><input type="checkbox" name="terms"> I accept the terms</label>
<button type="submit" class="btn is-disabled">Place order</button>
```

```css
.btn.is-disabled { opacity: 0.45; pointer-events: none; }
```

The button looks unavailable, but only the class says so. It is announced as "Place order, button", and `pointer-events: none` stops the mouse but not the keyboard, so Enter still submits the form.

## Before: a disabled button with no reason

```html
<label><input type="checkbox" name="terms" id="terms"> I accept the terms</label>
<button type="submit" id="place" disabled>Place order</button>
```

```js
terms.addEventListener('change', () => { place.disabled = !terms.checked; });
```

The state is exposed, so 4.1.2 passes, but Tab goes from the checkbox past the button to the next link, and nothing on the page says the box has to be ticked.

## After: a button that stays enabled and explains when pressed

```html
<label><input type="checkbox" name="terms" id="terms" aria-describedby="terms-error">
  I accept the terms</label>
<p id="terms-error" class="error" hidden>Tick the box to accept the terms.</p>
<button type="submit">Place order</button>
```

```js
form.addEventListener('submit', (event) => {
  if (terms.checked) return;
  event.preventDefault();
  termsError.hidden = false;
  terms.setAttribute('aria-invalid', 'true');
  terms.focus();
});
terms.addEventListener('change', () => {
  if (!terms.checked) return;
  termsError.hidden = true;
  terms.removeAttribute('aria-invalid');
});
```

The button is always reachable, always readable, and always does something. Pressed too early, it shows the error beside the checkbox and moves focus there, where a screen reader reads the label, the invalid state and the error. There is no disabled state to expose, so there is nothing to get wrong.

## After: aria-disabled with the reason beside it

```html
<label><input type="checkbox" name="terms" id="terms"> I accept the terms</label>
<button type="submit" id="place" aria-disabled="true" aria-describedby="place-why">Place order</button>
<p id="place-why">Tick the box to accept the terms before you place your order.</p>
```

```js
terms.addEventListener('change', () => {
  place.setAttribute('aria-disabled', String(!terms.checked));
  placeWhy.hidden = terms.checked;
});
form.addEventListener('submit', (event) => {
  if (place.getAttribute('aria-disabled') === 'true') event.preventDefault();
});
```

```css
.btn[aria-disabled="true"] { background: #e6e2dc; color: #57524c; cursor: not-allowed; }
```

`aria-disabled` exposes the state, as "Place order, button, dimmed" or "unavailable", but keeps the button in the Tab order, so keyboard and screen reader users reach it and hear the reason linked to it. Unlike `disabled`, it doesn't stop anything, so the script has to block the submit itself, including Enter in a text field, which also submits the form. The faded look is drawn from the attribute, and its text is kept readable.

## After: native disabled with a visible reason

```html
<label><input type="checkbox" name="terms" id="terms"> I accept the terms</label>
<p id="place-why">Tick the box to accept the terms before you place your order.</p>
<button type="submit" id="place" disabled>Place order</button>
```

```js
terms.addEventListener('change', () => {
  place.disabled = !terms.checked;
  placeWhy.hidden = terms.checked;
});
```

This is the smallest change: the browser exposes the state and blocks every way of pressing, and the reason is in visible text right above the button, where people reading the page meet it. It passes 4.1.2, but it is not enough on its own. Tab still skips the button, so a keyboard user or a screen reader user moving with Tab never reaches it, and the reason is only found by reading. Write the reason in readable text, not in the faint button.

## Which option to use

All three pass 4.1.2. Leave the button enabled and explain when it is pressed: it keeps the button where everyone can find and read it, gives a reason only when someone needs one, and uses the error handling the form needs anyway. Use `aria-disabled` when the design must show that the button isn't ready yet, accepting that the script, not the browser, has to stop the action. Native `disabled` with a visible reason is the smallest change to an existing disabled button, but people moving with Tab still pass it by, so treat it as a step on the way rather than the end.

## Implementation decisions

A button that is unavailable for a moment, rather than waiting on the person, is a different case. While an order is being sent, keep the button where it is, mark it `aria-disabled="true"` and ignore further presses, and say "Placing your order" in a status message. A native `disabled` there drops focus to the page body in some browsers, and the person loses their place.

Disabled links don't exist in HTML. A link with a `disabled` class is still a link. When a link must not be followed, remove its `href`, so it is no longer a link or a Tab stop, or turn it into a button with `aria-disabled`.

The faded look is where the contrast goes. Disabled controls are exempt from the contrast criteria, but the exemption doesn't make them readable. Draw an unavailable button with readable text and change something besides colour, such as a dashed border. Native `disabled` buttons are drawn in `GrayText` in forced colors; a button styled from `aria-disabled` loses that look unless you add a `forced-colors` rule.

Several reasons at once need several messages. When Place order waits for a delivery slot and the terms, list both beside the button, or let the enabled button show an error summary of everything that is missing, as in [error summaries that list every problem](/learn/error-summary). See [form errors that say what went wrong](/learn/accessible-form-errors) for the error beside each field.

Component libraries often expose a `disabled` prop that sets the attribute and an option, such as `focusableWhenDisabled`, that sets `aria-disabled` instead. Check which one the shared button uses, and which element in it takes focus.

## Verify the fix

1. Tab through the form from the top, and confirm focus reaches the button, or that, with native `disabled`, the reason is in visible text beside it.
2. With a screen reader, move to the button and confirm its state is announced the same way it is drawn: unavailable when it looks unavailable, and not when it doesn't.
3. Confirm the reason is read: through `aria-describedby` on focus, as the error when the button is pressed, or when reading down the page.
4. Press the button with a click, Enter and Space before the form is ready, and press Enter in a text field. Confirm nothing is submitted and you are told what is missing.
5. Complete the form, and confirm the button looks and is announced as ready, and submits once.
6. In forced colors and at 200% zoom, confirm the button and its reason can still be seen and read.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared button component changes.

## Limits

This is a starting pattern, not a guarantee for every form. Screen readers word `aria-disabled` and `disabled` differently ("dimmed", "unavailable", "disabled"), and some read `aria-describedby` only after a pause or on request. The error wording and its place on the page depend on the rest of your form. Test the whole journey, from an empty form to a placed order, with the assistive technology your users have.
