Accessible Toggles

I recently received some great advice from Scott O’Hara on improving the accessibility of a demo featuring a reduced-motion toggle (for this article). The demo sets the play-state of the animation depending on the user’s motion preferences (using the prefers-reduced-motion media query). Users could also click the button to toggle motion on and off, which also changes the text of the button to “Turn on motion” or “Turn off motion”. Here’s the original version:

See the Pen Reduced-motion toggle by Michelle Barker (@michellebarker) on CodePen.

Scott pointed out that some screenreaders wouldn’t announce the change to the button text when the button is clicked.

I have one suggestion though, per the accessibility of the toggle control. Changing the accessible name of the button will not be consistently announced by screen readers. NVDA and JAWS in particular do not announce the name change - I made a fork of your pen to demonstrate an alternative way to code the toggle control.

Let’s walk through the code and add Scott’s suggested improvements with a simpler version of the demo, so we can understand what is going on more clearly from an accessibility point of view. For the purpose of this article we’ll just focus on a button that plays or pauses the animation when clicked, without concerning ourselves with the additional complexity of the original, which includes checking the user’s system-level motion preferences and localStorage. (If you’re interested you can always go back and explore the final demo and accompanying article.)

This is the demo we’ll use as a starting point:

See the Pen Reduced-motion toggle (basic) by Michelle Barker (@michellebarker) on CodePen.

Here’s the button in our HTML:

<button data-toggle hidden>Turn off motion</button>

It includes the hidden attribute, as without JS the button doesn’t do anything, and we wouldn’t want to confuse users who don’t have JS enabled, or for whom JS fails to load. So we’ll hide it initially, then remove this attribute with JS.

In our JS code, we first set a variable for whether the animation should currently be paused, with an initial value of false (as the animation will be playing to begin with). We’ll display our button by removing the hidden attribute.

When the button is clicked, we’ll toggle the shouldPauseAnimation variable. Then we’ll set a custom property (to change the play state of the animation in CSS), and update the button text:

const toggle = document.querySelector('[data-toggle]')

let shouldPauseAnimation = false

// The button is hidden initially, as it won't work without JS. We need to make it visible
toggle.hidden = false

toggle.addEventListener('click', () => {
  shouldPauseAnimation = !shouldPauseAnimation

  if (shouldPauseAnimation) {
    // Pause animation
    toggle.innerText = 'Turn on motion'
    document.body.style.setProperty('--playState', 'paused')
  } else {
    // Play animation
    toggle.innerText = 'Turn off motion'
    document.body.style.setProperty('--playState', 'running')
  }
})

This toggles the custom property used in the CSS like so, with its original default value:

.circle {
  animation-play-state: var(--playState, running);
}

We now have a working motion toggle (hooray!). Unfortunately, as Scott explained, some screenreaders won’t announce the updated button text on click. Let’s add Scott’s improvements.

First, we’ll update the button’s HTML, so the text that informs the user of the current “on/off” state is inside a <span> with the aria-hidden attribute:

<button data-toggle hidden>
  Toggle motion
  <span aria-hidden="true" data-btn-text>: Off</span>
</button>

To a screenreader, the button will now simply say “Toggle motion”, with no indication of the state. We’ll give the button a role of switch, and add the aria-checked attribute with a value of true, to indicate an “on” state:

<button data-toggle hidden role="switch" aria-checked="true">
	Toggle motion
	<span aria-hidden="true" data-btn-text>: On</span></span>
</button>

In our JS code, we’ll update both the text inside the <span>, and the aria-checked attribute (in addition to toggling the --playState custom property:

const toggle = document.querySelector('[data-toggle]')
const buttonText = document.querySelector('[data-btn-text]')

let shouldPauseAnimation = false

toggle.hidden = false

toggle.addEventListener('click', () => {
  shouldPauseAnimation = !shouldPauseAnimation

  if (shouldPauseAnimation) {
    // Pause animation
    buttonText.innerText = ': Off'
    toggle.setAttribute('aria-checked', 'false')
    document.body.style.setProperty('--playState', 'paused')
  } else {
    // Play animation
    buttonText.innerText = ': On'
    toggle.setAttribute('aria-checked', 'true')
    document.body.style.setProperty('--playState', 'running')
  }
})

The result is a more accessible demo:

See the Pen Toggle switch for motion (basic) with accessibility improvements by Michelle Barker (@michellebarker) on CodePen.

The same principles could be applied to many different types of toggles, such as a dark/light mode toggle.

Read more about the aria-switch role.