# Invalid ARIA that misleads screen readers

Source: https://easeweb.dev/learn/valid-aria
Topics: Page structure > ARIA roles and attributes are valid (WCAG 4.1.2)
The fix: Either correct the ARIA so that every role, attribute and value is one ARIA defines (role="checkbox" with aria-checked set to "true" or "false"), or replace the custom box with a native checkbox inside a label, which needs no ARIA at all. Check the markup as rendered, in every state, not the template.
Test with: screen reader, keyboard, mouse
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors https://www.w3.org/TR/wai-aria-1.2/#aria-checked https://www.w3.org/TR/using-aria/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/checkbox_role https://dequeuniversity.com/rules/axe/4.10/aria-valid-attr-value https://dequeuniversity.com/rules/axe/4.10/aria-valid-attr https://dequeuniversity.com/rules/axe/4.10/aria-roles

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 shop's checkout has one extra option above the Place order button: a small square box and the words "Gift wrap this order". It isn't a real checkbox. It is a `div` drawn as a box, with a script that draws a tick when it is clicked or when Space is pressed on it, and ARIA to tell assistive technology what it is. The ARIA has one wrong word in it. In one version of the page, the script writes the state as `aria-checked="yes"` and `aria-checked="no"`. A screen reader user tabs to the box and hears "Gift wrap this order, checkbox, checked". They don't want gift wrap, so they press Space, but it still reads as checked. The box is now ticked, and it was empty before. In another version, the role is written as `aria-role="checkbox"`. The screen reader says only "Gift wrap this order", with no role and no state, and nothing at all when Space is pressed.

## Why it fails

ARIA is a fixed vocabulary. Each role, each attribute and each value an attribute can take is listed in the specification, and a browser builds the accessibility tree only from the words it knows. Anything else is an author error, and the specification tells browsers what to do with it, not what the author meant:

- `aria-checked` takes `"true"`, `"false"` or `"mixed"`. For an unknown value of a state exposed as on or off, ARIA tells browsers to treat anything other than an empty value or `"undefined"` as true. Chrome does: `"no"` reads as checked, exactly as `"yes"` does, so the box is announced as checked whatever it shows.
- `aria-role` is not an attribute at all. The browser ignores it, so the `div` keeps its own role, `generic`, which has no state. The correct `aria-checked` beside it is ignored too, because a generic element can't be checked.
- A `role` with no role ARIA defines, such as `role="check-box"`, is treated as if no role had been given, with the same result.

Each version fails 4.1.2 Name, Role, Value: the role or the state that a control must expose to assistive technology is wrong or missing, even though the source looks as though it sets both.

## Who is affected

The first version passes a quick check: tick the box with a screen reader running in Chrome, and it says "checked". Only the empty box gives it away, and that is the state people rarely test. Other browsers can treat the same unknown value differently, so two people on the same page can hear different things.

## What automation and AI miss

axe checks every ARIA word against the specification, so it reports `aria-checked="no"` (`aria-valid-attr-value`), `aria-role` (`aria-valid-attr`) and `role="check-box"` (`aria-roles`) reliably. It checks the page only in the state it finds it in, though: a script that writes `"no"` only after the first click passes a scan of the page as it loads. A scanner also can't tell whether a valid role is the right one, or whether `aria-checked` follows the tick. A quick fix can make things worse. Deleting the attribute axe names silences the report and leaves a box with no role. Renaming `aria-role` to `role` while the script still writes `"yes"` and `"no"` swaps one failure for the other. Adding `aria-checked` to a native checkbox "to be safe" adds an attribute browsers ignore there, and axe reports it under `aria-conditional-attr` as soon as it disagrees with the real state.

## Before: the state written as "yes" and "no"

```html
<div class="tick-box" role="checkbox" aria-checked="no" tabindex="0">
  Gift wrap this order
</div>
```

```js
const box = document.querySelector('.tick-box');
function toggle() {
  const ticked = box.getAttribute('aria-checked') !== 'yes';
  box.setAttribute('aria-checked', ticked ? 'yes' : 'no');
}
box.addEventListener('click', toggle);
box.addEventListener('keydown', (event) => {
  if (event.key === ' ') {
    event.preventDefault();
    toggle();
  }
});
```

```css
.tick-box[aria-checked="yes"]::before {
  content: "✓";
}
```

The role is right and the tick is drawn, but neither `"yes"` nor `"no"` is a value of `aria-checked`, so Chrome reads both as checked. An empty box is announced as checked.

## Before: the role written as aria-role

```html
<div class="tick-box" aria-role="checkbox" aria-checked="false" tabindex="0">
  Gift wrap this order
</div>
```

Here the script writes `"true"` and `"false"`, as it should. But `aria-role` is not an ARIA attribute, so the element has no role, and with no role its `aria-checked` is ignored. A screen reader reads the words, and nothing says they can be ticked or whether they are.

## After: the ARIA written as ARIA defines it

```html
<div class="tick-box" role="checkbox" aria-checked="false" tabindex="0">
  Gift wrap this order
</div>
```

```js
const box = document.querySelector('.tick-box');
function toggle() {
  const ticked = box.getAttribute('aria-checked') !== 'true';
  box.setAttribute('aria-checked', String(ticked));
}
box.addEventListener('click', toggle);
box.addEventListener('keydown', (event) => {
  if (event.key === ' ') {
    event.preventDefault();
    toggle();
  }
});
```

```css
.tick-box[aria-checked="true"]::before {
  content: "✓";
}
```

`role="checkbox"` gives the box its role, and `aria-checked` holds `"true"` or `"false"`, so a screen reader says "Gift wrap this order, checkbox, not checked" and then "checked" after Space or a click. The tick is drawn from `aria-checked` itself, so what is shown and what is heard can't drift apart. The page must render `aria-checked="true"` when gift wrap is already chosen, for example in a saved basket.

## After: a native checkbox

```html
<label class="gift-option">
  <input type="checkbox" name="gift-wrap">
  Gift wrap this order
</label>
```

A native checkbox has its role, its state and its Space key built in, and the label gives it its name, so it needs no ARIA and no script. Clicking the words ticks it too. Style the box with CSS if the design needs its own look: `appearance: none` on the input lets you draw it, and `:checked` draws the tick.

## Which option to use

Both pass. The corrected ARIA fixes the component as it stands, but its role, state, Space key and saved state are all yours to keep right, and every future edit can bring an invalid word back. The native checkbox does the same job with no ARIA and no script, so there is nothing left to misspell, and the browser keeps it working. Use it. Keep the corrected ARIA for a component you can't restructure yet.

## Implementation decisions

**Check what is rendered, not the template.** Frameworks and components turn values into attributes in their own ways. A boolean prop may render as `"true"`, as an empty attribute, or not at all, and a component may pass through whatever string it is given. Read the attribute in the browser's inspector after each change of state.

**Other invalid ARIA that axe reports.** The same failure takes other forms, each with its own rule. They share one fix: use the word ARIA defines for that place, or the native element that needs none.

- A role ARIA doesn't define, or an abstract one such as `role="input"` or `role="widget"` (`aria-roles`). Use the real role, such as `checkbox` or `navigation`, or the HTML element, such as `nav`.
- An attribute on a role that doesn't support it, such as `aria-checked` on a button (`aria-allowed-attr`). A button that stays pressed takes `aria-pressed`: see [toggle buttons that don't say whether they are pressed](/learn/toggle-buttons). The `aria-checked` left on a `div` by `aria-role` is reported here too.
- ARIA that restates a native control's state, such as `aria-checked` on `<input type="checkbox">`, reported once it disagrees with the real state (`aria-conditional-attr`). Delete it: the browser uses the real `checked` either way.
- A deprecated role, such as `role="directory"` (`aria-deprecated-role`). Use `list`, or `ul`.
- A braille label with no name to go with it, such as `aria-braillelabel` on an element with no other name (`aria-braille-equivalent`, sent for review). Give the element its ordinary name first.

**A misspelling that works in some browsers.** Chrome and Safari also accept `aria-labeledby` for `aria-labelledby`, but Firefox doesn't, so a label can look fine in Chrome and be missing in Firefox. axe reports it under `aria-valid-attr`; spell it with two Ls.

**A reference to nothing.** An `aria-labelledby` or `aria-describedby` that points at an id not on the page is ignored, as if it were missing. axe sends it for review under `aria-valid-attr-value` rather than reporting it. Make sure the id exists whenever the attribute does, and write it without `#`.

**Valid but wrong is covered elsewhere.** A name on a `div` or `span` that can't hold one is in [aria-label on divs and spans that screen readers skip](/learn/aria-label-on-generic). A state left out altogether is in [toggle switches that don't say whether they are on](/learn/toggle-switches). `aria-hidden` on something that can take focus is in [aria-hidden content that keyboard focus still reaches](/learn/aria-hidden-focus), and list roles without their items are in [lists built from divs, icons and typed numbers](/learn/list-markup).

## Verify the fix

1. Run axe with the box empty, then tick it and run it again. Confirm `aria-valid-attr`, `aria-valid-attr-value`, `aria-roles` and `aria-allowed-attr` report nothing in either state.
2. In the browser's accessibility tree, check the box in both positions: its role is checkbox, its name is "Gift wrap this order", and its checked state matches the tick.
3. With a screen reader, Tab to the box, then tick and clear it with Space and with a click. Each change announces the new state, and it matches what is shown.
4. Repeat the screen reader check in a second browser and screen reader pair, such as NVDA with Firefox on Windows and VoiceOver with Safari on a Mac.
5. Reload the page with gift wrap already chosen and confirm the first announcement says checked.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever the component or the code that saves its state changes.

## Limits

This is a starting pattern, not a guarantee for every application. Class names are placeholders, and the checkbox stands for any custom control whose ARIA is written by hand. Browsers handle author errors in their own ways and change between versions, so a page that sounds right in one browser proves little. Valid ARIA is not the same as correct ARIA: a scan that reports nothing can't tell you that a role suits the control or that its state follows the screen. Test the complete journey with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
