🛠️ Tabby: Screen reader 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!

🛠️ Tabby: Screen reader accessibility

First, we need to add these three roles to Tabby.

  1. Tab
  2. Tablist
  3. Tabpanel

Adding roles

Each <button> element is a tab. Let’s start by adding role="tab" to each button.

<div class="tabby">
  <div class="tabs">
    <button ... role="tab"> Digimon </button>
    <button ... role="tab"> Pokemon </button>
    <button ... role="tab"> Tamagotchi </button>
  </div>
  <!-- ... -->
</div>

When you Tab into a tab, you’ll hear the screen reader say “Tab” instead of “Button”.

NVDA and Tabs

NVDA goes into forms mode if you Tab into a tab. This lets NVDA users switch between tabs with arrow keys.

Notice NVDA says “Tab 1 of 3”, “Tab 2 of 3”, and “Tab 3 of 3” when you switch between Tabs while Voiceover doesn’t. We’ll fix this next.

Tablist role

Tabs need to be placed inside an element with a tablist role. Let’s add role="tablist to .tabs.

<div class="tabby">
  <div class="tabs" role="tablist">
    <button ... role="tab"> Digimon </button>
    <button ... role="tab"> Pokemon </button>
    <button ... role="tab"> Tamagotchi </button>
  </div>
  <!-- ... -->
</div>

Once you do this, Voiceover says “Tab 1 of 3”, “Tab 2 of 3”, and “Tab 3 of 3”.

tablist should have an accessible name. This lets screen readers understand what the group of tabs are for. In this case, let’s use “Virtual Pets” as the accessible name.

We’ll use aria-label to set the accessible name.

<div class="tabby">
  <div class="tabs" role="tablist" aria-label="Virtual Pets">
    <button ... role="tab"> Digimon </button>
    <button ... role="tab"> Pokemon </button>
    <button ... role="tab"> Tamagotchi </button>
  </div>
  <!-- ... -->
</div>

Tabpanel role

The tabpanel role tells screen reader users they’re on the contents of a tab. We’ll add role="tabpanel" to each tab content.

The tabpanel role tells users they’re on the contents of a tab.

<div class="tab-contents">
  <section id="digimon" role="tabpanel" ... > <!-- ... --> </section>
  <section id="pokemon" role="tabpanel" ... > <!-- ... --> </section>
  <section id="tamagotchi" role="tabpanel" ... > <!-- ... --> </section>
</div>

Each tabpanel should have an accessible name.

  • Digimon’s tabpanel should read the “Digimon”.
  • Pokemon’s tabpanel should read “Pokemon”.
  • Tamagotchi’s tabpanel should read “Tamagotchi”.

The words “Digimon”, “Pokemon”, and “Tamagotchi” exists in the tabpanels in the <h2> element. We can use aria-labelledby to point to this <h2> element.

<section
  id="digimon"
  role="tabpanel"
  aria-labelledby="digimon-title"
  ...
>
  <!-- ... -->
  <h2 id="digimon-title">Digimon</h2>
</section>
<section
  id="pokemon"
  role="tabpanel"
  aria-labelledby="pokemon-title"
  ...
>
  <!-- ... -->
  <h2 id="pokemon-title">Pokemon</h2>
</section>
<section
  id="tamagotchi"
  role="tabpanel"
  aria-labelledby="tamagotchi-title"
  ...
>
  <!-- ... -->
  <h2 id="tamagotchi-title">tamagotchi</h2>
</section>

Screen readers will now announce the tab panel. Voiceover calls them “Tab panel”.

And NVDA calls them “property page”.

Screen reader users need a way to navigate to the tab panel.

WCAG recommends allowing users to move into the tab panel with the Tab key. Heydon Pickering, on the other hand, recommends allowing users to move into the tab panel with .

We’ll apply both their recommendations for Tabby.

First, we need to set tab panels’ tabindex to -1. This lets us focus on the tab panel.

<div class="tab-contents">
  <section id="digimon" role="tabpanel" tabindex="-1" ... > <!-- ... --> </section>
  <section id="pokemon" role="tabpanel" tabindex="-1" ... > <!-- ... --> </section>
  <section id="tamagotchi" role="tabpanel" tabindex="-1" ... > <!-- ... --> </section>
</div>

We need to add an event listener to listen to both Tab and . We’ll listen to the keydown event.

tabsList.addEventListener('keydown', event => {
  // ...
})

We only want to do something if the user pressed Tab or . If they don’t press these two keys, we’ll bail from the callback.

tabsList.addEventListener('keydown', event => {
  const key = event.key
  if (key !== 'Tab' && key !== 'ArrowDown') return
})

We won’t change Tab's behaviour if the user pressed Shift + Tab.

tabsList.addEventListener('keydown', event => {
  const key = event.key
  if (event.shiftKey) return
  if (key !== 'Tab' && key !== 'ArrowDown') return
})

Next, we need to find the active tab.

If a keydown even originates from tabsList, it must originate from the active tab. Therefore, the event target is the active tab.

tabsList.addEventListener('keydown', event => {
  // ...
  const tab = event.target
})

Next, we need to shift focus to the tab panel. We can find the id of the tab panel from the tab’s data-target property.

tabsList.addEventListener('keydown', event => {
  // ...
  const tab = event.target
  const target = tab.dataset.target
  const tabPanel = tabby.querySelector('#' + target)
})

We can shift focus with the focus method. Remember to use event.preventDefault so the default Tab behaviour doesn’t activate.

tabsList.addEventListener('keydown', event => {
  // ...
  event.preventDefault()
  tabPanel.focus()
})

Voiceover will now say the accessible name plus “Tab Panel”. It waits for the user’s next action before saying anything else.

NVDA, on the other hand, speaks everything inside the tabpanel.

Selecting a tab

We need to set aria-selected="true" to tell screen reader users which tab is selected.

Since Tabby begins with the first tab as the selected tab, we’ll add aria-selected="true" to the first tab.

<div class="tabby">
  <div role="tablist" aria-label="Virtual Pets" ... >
    <button class="tab is-selected" role="tab" aria-selected="true" ... > Digimon </button>
    <button class="tab" role="tab" ... > Pokemon </button>
    <button role="tab" ... > Tamagotchi </button>
  </div>
  ...
</div>

When the user presses , we need to set aria-selected of the second tab to true, and aria-selected of the first tab to false.

const selectTab = tab => {
  const target = tab.dataset.target
  const tabContent = tabby.querySelector('#' + target)

  // Selects a tab
  tabs.forEach(t => {
    t.classList.remove('is-selected')
    t.removeAttribute('aria-selected')
    // ...
  })

  tab.classList.add('is-selected')
  tab.setAttribute('aria-selected', 'true')
  // ...
}

Ideally, if you use Voiceover, you should hear “Pokemon, selected, Tab 2 of 3”. But you’ll only hear “Pokemon”.

This happens because Voiceover’s shortcut kicks in. We can fix this by adding event.preventDefault.

tabsList.addEventListener('keydown', event => {
  // ...
  if (targetTab) {
    event.preventDefault()
    targetTab.click()
  }
})

For NVDA, you don’t even need to set aria-selected. It selects a tab automatically when the tab gets clicked.

Voiceover’s next item shortcut

Tabs don’t get selected if you use VO + to get to the next tab. This happens because Voiceover shortcuts take other shortcuts. The browser doesn’t even know was pressed!

If you used VO + to move to the next tab, you need to use VO + Space to select the tab.

The same applies to VO + .

That’s it!