🛠️ Tabby: Screen reader accessibility
First, we need to add these three roles to Tabby.
Tab
Tablist
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”.
Your browser doesn't support embedded videos. Watch the video here instead.
NVDA and Tabs
NVDA goes into forms mode if you Tab into a tab. This lets NVDA users switch between tabs with arrow keys.
Your browser doesn't support embedded videos. Watch the video here instead.
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”.
Your browser doesn't support embedded videos. Watch the video here instead.
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>
Your browser doesn't support embedded videos. Watch the video here instead.
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”.
Your browser doesn't support embedded videos. Watch the video here instead.
And NVDA calls them “property page”.
Your browser doesn't support embedded videos. Watch the video here instead.
Navigating to a tab panel
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.
Your browser doesn't support embedded videos. Watch the video here instead.
NVDA, on the other hand, speaks everything inside the tabpanel.
Your browser doesn't support embedded videos. Watch the video here instead.
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>
Your browser doesn't support embedded videos. Watch the video here instead.
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”.
Your browser doesn't support embedded videos. Watch the video here instead.
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()
}
})
Your browser doesn't support embedded videos. Watch the video here instead.
For NVDA, you don’t even need to set aria-selected
. It selects a tab automatically when the tab gets clicked.
Your browser doesn't support embedded videos. Watch the video here instead.
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.
Your browser doesn't support embedded videos. Watch the video here instead.
The same applies to VO
+ ←
.
That’s it!