🛠️ Off-canvas: Accessibility

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

🛠️ Off-canvas: Accessibility

This might be the first time you’re working on a component with screen readers. We’ll take it slow; I’ll walk you through every step of the way.

The first thing we want to do is audit what we’ve built with a screen reader.

The menu button

When Voiceover reaches the menu button, it says “Menu, Button”. This informs the user they’re on a <button> element with text that says “Menu”.

The button doesn’t say whether the menu is opened or closed. We can add this context by adding aria-expanded="false" into the HTML.

<button class="menu-button" aria-expanded="false">
  <span>Menu</span>
</button>

Expanding the menu

First, we need to set aria-expanded to true. If the user tabs into the button again, they’ll know the menu is expanded.

const openOffcanvasMenu = () => {
  // ...
  button.setAttribute('aria-expanded', 'true')
}

When a user expands the menu, Voiceover says “navigation”. This happens because we focused on the <nav> element.

NVDA says a bunch of stuff. This example shows you different screen readers say different things.

We can improve this area by giving the <nav> element (which is a landmark) an accessible name. Let’s call it “Main navigation”.

We’ll use aria-label since the words “Main navigation” isn’t shown on screen.

<nav ... aria-label="Main navigation">
  <!-- ... -->
</nav>

Now Voiceover reads “Main navigation, navigation”.

Most screen readers users will use the Tab key to get through the navigation (since navigations contain links). Here’s what it looks like with Voiceover.

In this case, Voiceover says “Link” for each item. This “Link” comes from the link role, which is built into <a> with a href attribute.

This is why we need to use the right elements for the right job.

They may also use the “Next item” shortcut.

Command Shortcut
Next item (Voiceover) VO + →
Next item (NVDA) ↓

There’s not much difference between VO + → and Tab on Safari.

If you use NVDA, you’ll notice it says:

  • “Item, Link” when you press Tab
  • “Link, Item” when you press ↓

No biggie here. We can’t do anything about this small NVDA quirk.

Closing the menu

We need a way to let screen reader users close the menu. And we have one—the Escape key—but they don’t know it exists.

We can tell users they can use the escape key to close the navigation.

<nav class="nav" tabindex="-1" aria-label="Main navigation">
  <ul> <!-- ... --> </ul>
  <p>Press Escape to close this navigation.</p>
</nav>

We want to hide this piece of text from visually from non-screen reader users. The best way to hide text is with this CSS:

.sr-only {
  position: absolute;
  width: 1px;
  height: auto;
  margin: 0;
  padding: 0;
  border: 0;
  clip: rect(0 0 0 0);
  overflow: hidden;
  white-space: nowrap;
}

We can use it like this:

<nav class="nav" tabindex="-1" aria-label="Main navigation">
  <ul> <!-- ... --> </ul>
  <p class="sr-only">Press Escape to close this navigation.</p>
</nav>

If a user gets through the navigation with the “Next Item” command, they’ll hear the message.

Unfortunately, they’ll miss the escape message if they used Tab. We need a more robust way to let them close the navigation.

One thing we can do is link the navigation and escape text with aria-describedby.

<nav ... aria-label="Main navigation" aria-label="hint">
  <ul> <!-- ... --> </ul>
  <p id="hint" class="sr-only">Press Escape to close this navigation.</p>
</nav>

This works on both Voiceover.

It also works on NVDA, but NVDA only aria-describedby after everything in the navigation. Users may miss the escape text.

We need something even more robust.

What we can do is create a dedicated button to close the menu.

Creating a close menu button

Here’s the HTML for the button. (You can find the necessary CSS in the starter file).

<nav ... aria-label="Main navigation" aria-label="hint">
  <button class="close-button" aria-label="Close navigation">
    <svg viewBox="0 0 20 20">
      <path d="M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z"/>
    </svg>
  </button>
  <ul> ... </ul>
</nav>

After adding the close menu button, we need to correct our JavaScript to use the correct button elements.

// Change this
const button = document.querySelector('button')

// To this
const menuButton = document.querySelector('.menu-button')

// Make sure you change `button` to `menuButton` in the rest of the code.
// ...

We also need to add an event listener to close the menu.

const closeButton = document.querySelector('.close-button')

closeButton.addEventListener('click', event => {
  closeOffcanvasMenu()
})

When we close the navigation, we need to set aria-expanded on the menu button back to false. This would tell screen reader users the navigation is collapsed.

const closeOffcanvasMenu = () => {
  // ...
  button.setAttribute('aria-expanded', 'false')
}

Tabbing back onto the menu

Blind users cannot see the off-canvas menu. When they activate the menu button, we bring them to the <nav> element with the focus method. This makes them think the <nav> is directly after the menu button.

<!-- What a screen reader user believes the HTML to be -->
<button class="menu-button">
<nav aria-label="Main navigation"> ... </nav>

In this case, it makes sense to bring focus back to the menu button if they press Shift + Tab on <nav>,

To do this, we need to create an event listener that listens for Shift + Tab.

document.addEventListener('keydown', event => {
  if (event.key !== 'Tab') return
  if (!event.shiftKey) return
})

If the event target is <nav>, we return focus to the menu button.

document.addEventListener('keydown', event => {
  // ...
  if (event.target === menu) {
    event.preventDefault()
    menuButton.focus()
  }
})

Users may Tab into the first element (the close button) before pressing Shift + Tab. In this case, it makes sense to bring the user back to the menu button as well.

document.addEventListener('keydown', event => {
  if (event.key !== 'Tab') return
  if (!event.shiftKey) return

  if (event.target === menu || event.target === closeButton) {
    event.preventDefault()
    menuButton.focus()
  }
})

That’s it!