Web Platform

toggleAttribute and ARIA properties

7 min read

toggleAttribute, a modern method on DOM elements, got added around late 2018 to all modern browsers. It is a convenience function (albeit unchainable) to either add or remove an attribute.

When creating an accessible interface, you will have to dynamically modify ARIA attributes. Boolean ARIA attributes (like aria-disabled) can be set to true or false. Any other value (including none at all) are treated as the default value (usually false). Toggling these attributes is a frequent use case. Here’s how you’d like to do that:

<div id="toggleDisabled" role="button" tabindex="-1">
  Custom button
</div>
<script>
  toggleDisabled.toggleAttribute('aria-disabled'); toggleDisabled.innerText =
  toggleDisabled.getAttribute('aria-disabled') === 'true' ? 'Disabled' : 'Enabled';
</script>
Custom button

Enabled, you say? But I thought I toggled aria-disabled when it previously did not exist. It should be disabled then, right? Wrong. aria-disabled must literally be set to true. This is contrary to HTML boolean attributes, like disabled on a button:

<button id="domDisabled">Custom button</button>
<script>
  domDisabled.disabled = true; // Using the `disabled` property setter sets this HTML5 boolean attribute to an empty
  string, which means `true` domDisabled.innerText = domDisabled.getAttribute('disabled') === '' ? 'Disabled' :
  'Enabled';
</script>

When I write <button disabled>, I of course want a disabled button. <div role="button" aria-disabled>, surprisingly, represents a semantically enabled button.

All HTML boolean attributes are available as a property on the DOM element which implements them, serving as getters and setters. The reverse is not necessarily true.. toggleAttribute ends up being quite useless as a result. The only use case I can imagine is to interact with Web Components that do not provide their attributes as properties.

Fixing this is as simple as adding a third argument to toggleAttribute The second argument to toggleAttribute is used to force an attribute on or off, like .classList.toggle. The third argument should provide the value for the on case. Right now, whenever an attribute is toggled on, it is always set to "".

Safari implemented the ARIAMixin specification, with Blink (Chrome, etc.) following up early 2020. This specification added additional properties for many aria- attributes to all DOM elements. To refer to the aria-disabled attribute, you can use ariaDisabled:

<div id="ariaDisabledToggle" role="button" tabindex="-1">
  Custom button
</div>
<script>
  ariaDisabledToggle.ariaDisabled = !ariaDisabledToggle.ariaDisabled; ariaDisabledToggle.innerText =
  ariaDisabledToggle.getAttribute('aria-disabled') === 'true' ? 'Disabled' : 'Enabled';
</script>
Custom button

Not so fast. Did you spot the mistake? Committees never learn. ariaDisabled can be either a string or null (no attribute set). If you set it to a non-string, non-null value, it is coerced to a string. Invalid values (like if you were to do .ariaDisabled = {}) are treated as false.

As you would expect, the first time you toggle ariaDisabled, it becomes 'true'. Not for the reason you were probably thinking, though. !null evaluates to true, so ariaDisabled is set to String(!null) (which evaluates to 'true'). Now, when you toggle it again, look what happens:

<div id="ariaDisabledToggle2" role="button" tabindex="-1">
  Custom button
</div>
<script>
  ariaDisabledToggle2.ariaDisabled = !ariaDisabledToggle2.ariaDisabled; ariaDisabledToggle2.ariaDisabled =
  !ariaDisabledToggle2.ariaDisabled; ariaDisabledToggle2.innerText = ariaDisabledToggle2.getAttribute('aria-disabled')
  === 'true' ? 'Disabled' : 'Enabled';
</script>
Custom button

Works as expected, right? Glad I was extra careful there to write a unit test to test the “return to default” behavior.

JavaScript strings follow one simple rule when coerced to a boolean: if they are empty (''), they evaluate to false. Otherwise, they evaluate to true:

  • Boolean('true') returns true
  • Boolean('false') returns true
  • Boolean('') returns false

That means !'true' evaluates to 'false'.

What if we try to toggle ariaDisabled for a third time, though? Now its value will be 'false'. !'false' evaluates to false. Let’s just see what happens:

<div id="ariaDisabledToggle3" role="button" tabindex="-1">
  Custom button
</div>
<script>
  ariaDisabledToggle3.ariaDisabled = !ariaDisabledToggle3.ariaDisabled; ariaDisabledToggle3.ariaDisabled =
  !ariaDisabledToggle3.ariaDisabled; ariaDisabledToggle3.ariaDisabled = !ariaDisabledToggle3.ariaDisabled;
  ariaDisabledToggle3.innerText = ariaDisabledToggle3.getAttribute('aria-disabled') === 'true' ? 'Disabled' :
  'Enabled';
</script>
Custom button

No matter how many times we try to toggle ariaDisabled using this approach, the button will forever remain enabled.

This is not a JavaScript problem. Implicit type coercions are generally considered to be confusing. It is a good thing that Boolean('false') does not get any special treatment. The real problem is that boolean ARIA properties coerce to a string. HTML attributes don’t do this. button.disabled can only ever be true or false.

Negating a boolean ARIA property correctly

Read section Negating a boolean ARIA property correctly

What you actually want to do here is have ariaDisabled become true if it is any value that is not 'true', and false otherwise:

<div id="ariaDisabledInterval" role="button" tabindex="-1">
  Custom button
</div>
<script>
    function toggleDisabled() {
      ariaDisabledInterval.ariaDisabled = ariaDisabledInterval.ariaDisabled !== 'true';
      ariaDisabledInterval.innerText = ariaDisabledInterval.ariaDisabled === 'true' ? 'Disabled' : 'Enabled';
    }
    toggleDisabled();
    setInterval(toggleDisabled, 2000);
</script>
Custom button

This odd behavior is why I used .getAttribute('aria-disabled') === 'true' in all the examples. To check if an ARIA boolean property is true, you must compare it to the string 'true': .ariaDisabled === 'true'