Post contents
Have you ever look through a codebase and just see a sea of div
s as far as the eye can see?
<div> <div>Add todo item</div> <div class="todos"> <div>Play games</div> <div>Eat ice cream</div> <div>Do chores</div> </div></div>
While this may show the contents on screen, it's not the most readable code there is. Instead, let's replace these div
s with elements that describe what they're doing:
<div> <button>Add todo item</button> <ul class="todos"> <li>Play games</li> <li>Eat ice cream</li> <li>Do chores</li> </ul></div>
See, the HTML specification gives us a wide range of HTML elements we can use, each with their own meaning and intent behind them.
A
ul
is anunordered list
, while ali
is alist item
.
Not only does this help codebase readability, it helps immensely with accessibility and UX. For example, compare and contrast the two versions of HTML without any added CSS or JavaScript.
The div
soup:
The correct HTML tags:
- Play games
- Eat ice cream
- Do chores
Notice how, by default, the correct HTML tags show bullet points next to the list? Or how the button actually is clickable?
This is because the browser knows what a button
is, and will apply default styling and behavior to the element, that you can then overwrite if need be. Without this information, it doesn't know how to handle a div
in any special kind of way.
Similarly, a screen-reader doesn't know that our first <div class="todos">
was a list, and as such wouldn't indicate to the user that it has a list of items, or how many items are in the list. By using an ul
, it will do all of that for us, without any additional code on our end.
We can see a before and after of this in action using the macOS voiceover screen-reader:
These HTML elements are not just supported in .html
files; React, Angular, and Vue support all valid HTML elements.
Note
There are a lot of HTML elements supported by the latest HTML specification. To find the right element for the job, it may be worthwhile to explore the HTML elements list from MDN until you're familiar with the list.
Page Structure
While individual elements like button
or ul
provide context on a micro-level, there's also a broader understanding of a page's layout you can convey to the user via proper semantic markup.
Take a homepage like ours:
When the user is able to see the page, they might typically break it down into different visual components like so:
Similarly, we can convey the same structure of our page more programmatically using semantic markup.
Instead of:
<!-- Inaccessible, do not use --><div class="header"></div><div class="main-contents"> <div class="top-section"></div> <div class="recent-articles-section"></div></div>
Our markup should instead be:
<header></header><main> <section class="top"></section> <section class="recent-articles"></section></main>
These structure-based elements, often called "landmark elements", help non-sighted users navigate the page better and even provides a good SEO boost to your site.
Landmark elements and their meaning
<header>
: The header of a site, consistent between different pages.<nav>
: The navigation elements of a site.<main>
: The main contents of a site.<section>
: A grouping of related items, most often with an associated heading.<article>
: A group of related items with all the relevant context contained within.<aside>
: A tangential item to the main contents on a page.<footer>
: The footer of a site, consistent between different pages.
Landmarks and screen readers
Landmark elements are a great way to help users navigate your site; while sighted users can see the page structure, non-sighted users need to rely on screen readers to help them navigate the page.
This can be done in a number of ways, including Voiceover's "Rotor" feature, which allows users to quickly navigate between different landmarks on the page:
A note on <section>
elements and screen readers
While <section>
elements are a great way to group related items together and keep semantic meaning, they don't always act as a landmark element.
Check the difference between how a screen-reader treats a <section>
with an aria-label
and one without:
While this may lead you to assume a
<section>
without a label is useless, it does have its place as a semantic element.If nothing else, a
<section>
provides information to your team that a given bit of markup is a section of the page, and not just a randomdiv
with a class name.
Warning
After learning this, you may be tempted to add
aria-label
attributes to all of your<section>
elements; do not do this.Not only do these kinds of labels typically end up duplicative with the heading of the section, they can also lead to confusion for screen reader users.
We'll learn more about how to structure a page with headings in the next chapter all about Text.
Landmarks Screen Reader Cheat Sheet
Here's a list of screen reader commands related to landmarks in Voiceover, NVDA, and JAWS:
Screen Reader | Command | Shortcut |
---|---|---|
Voiceover | Show landmark list (via the Voiceover rotor in the demo above) | Command (⌘) + Ctrl + U |
NVDA | Show a list of all elements, including landmarks | Insert + F7 |
NVDA | Go to next landmark | D |
JAWS | Show a list of all elements, including landmarks | Insert + F3 |
JAWS | Go to next landmark | R |
JAWS | Go to the main content region | Q |
ARIA
Sometimes we have custom UI requirements. Like, really custom UI requirements. We may want a dropdown that also has the ability to filter results as the user types.
While some of this component has clear analogs in HTML elements:
- The search input should be an
input
component - The dropdown list should be an
ul
withli
to indicate that it's a list
Other parts of this UI are unclear how to communicate to the user at first glance.
How do we indicate to the user that the suggestion dropdown is active? How can we associate the text input element with the suggestion list element for screen readers?
This type of ultra-custom UI is where ARIA comes into play.
ARIA is an acronym for "Accessible Rich Internet Applications", and is a collection of HTML attributes that help provide additional UI information to the end user.
For example, the dropdown arrow might have an attribute of aria-expanded="true"
or aria-expanded="false"
to indicate to screen readers that the dropdown is expanded or not.
The following HTML:
<button aria-expanded="true">States</button>
Might be read by a screen reader as "States button, expanded", which tells our user that they have more information they can access pertaining to the button.
Likewise, the aria-controls
attribute tells the assistive technology which element the button expands. This attribute takes an HTML id
's name and enables the user to quickly jump to the controlled element using a user-defined key combo.
<button aria-expanded="true" aria-controls="states-list">States</button><ul id="states-list"> <li>Alabama</li> <li>Alaska</li> <li>Arizona</li> <!-- ... --></ul>
This is a wildly incomplete example of a "Select-only Combobox" UI component. A more complete example of such a component can be found on the W3C's website, though even they admit their example is for demonstration purposes only.
This component in particular has significantly more nuance than you might assume, and as such is an extremely tricky component to implement properly. If you're looking to add one to your production site, make sure you do sufficient user testing before shipping to your generalized end-users.
While a complete list of these ARIA attributes are out of the scope of this book, you can find a reference to them on MDN. Each comes with their own use-cases and nuance.
ARIA Roles
What if we need an accessible UI pattern that HTML doesn't provide with native elements?
This is where an attribute comes into play that we should proceed to use with immense caution; role
.
The role
attribute allows us to tell the browser that an element should be treated as a different element than it actually is, including some elements that HTML itself doesn't provide.
For example, say we wanted to provide the user tabs in their UI:
This is a common UI pattern that HTML doesn't provide a native element for. As a result, our markup might look something like:
<div> <ul role="tablist"> <li role="tab" id="javascript-tab" aria-selected="true" aria-controls="javascript-panel"> JavaScript </li> <li role="tab" id="python-tab" aria-selected="false" aria-controls="python-panel"> Python </li> </ul> <div role="tabpanel" id="javascript-panel" aria-labelledby="javascript-tab"> <code>console.log("Hello, world!");</code> </div> <div role="tabpanel" id="python-panel" aria-labelledby="python-tab"> <code>print("Hello, world!")</code> </div></div>
Here, the role
enables us to tell the user that there is a list of tabs, aria-controls
and aria-labelledby
tells the user which contents belong to which tab, and aria-selected
informs the user which tab is currently selected.
Keep in mind, we have to change these
aria
attributes on-the-fly as the information changes; say, with thearia-selected
indicating which tab is active.HTML does not provide a way to automatically change the
aria
attributes for us without JavaScript.We'll build an interactive version of this
tab
component using React, Angular, and Vue later in this chapter that handles these things.
While role
is imperative in its usage here, it can lead to subpar or even actively hostile user experiences for assistive technologies.
This is because, using role
, you have the ability to tell HTML that one element should be reflected to the end-user as an entirely different element, without actually providing any of the expected functionality.
To explain this more, let's look at how an HTML button
works.
Defaulted HTML Roles
When you create an HTML element like button
, the browser implicitly assigns it a role
internally, regardless of if you assigned one or not.
In this case:
<button>Click me!</button>
Is implicitly treated by the browser as having role="button"
assigned to it.
If that's the case, then surely
<div type="button">
must act the same as a<button>
, right?
Not quite.
While you could create a partially analogous button
element using a div
:
<!-- Inaccessible, do not use --><div tabindex="0" role="button">Save</div>
You might notice a problem with it when displayed on a web page:
Notice that the fake "button" here doesn't appear to "press" down? There's no styling to indicate when the user is hovered over the "button", nor is there any visual indication when the user is hovered over the "button" with their mouse.
Not only that, but if we go to add a click
event to the div
, it won't work when the user presses the Enter
or Space
keys, which is the expected behavior of a button.
This is why it's often highly discouraged to use role
in place of an HTML element with an implicit role
enabled; they simply don't have feature parity without a substantial amount of work and expertise.
Building a tab component with ARIA
Now that we've seen a few examples of accessible, but non-interactive, markup let's see what we can do to breath life into these UI components using a framework.
Namely, I want to demonstrate how we can build our own accessible tab component using aria attributes:
Tabs Markup
Let's start by reusing our markup from the previous section and binding the aria
attributes to the correct reactive values.
- React
- Angular
- Vue
const tabList = [ { id: "javascript-tab", label: "JavaScript", panelId: "javascript-panel", content: `console.log("Hello, world!");`, }, { id: "python-tab", label: "Python", panelId: "python-panel", content: `print("Hello, world!")`, },];const App = () => { const [activeTabIndex, setActiveTabIndex] = useState(0); return ( <div> <ul role="tablist"> {tabList.map((tab, index) => ( <li key={tab.id} role="tab" id={tab.id} tabIndex={index === activeTabIndex ? 0 : -1} aria-selected={index === activeTabIndex} aria-controls={tab.panelId} onClick={() => setActiveTabIndex((_) => index)} > {tab.label} </li> ))} </ul> {tabList.map((tab, index) => ( <div key={tab.panelId} role="tabpanel" id={tab.panelId} aria-labelledby={tab.id} style={{ display: index !== activeTabIndex ? "none" : "block" }} > <code>{tab.content}</code> </div> ))} </div> );};
@Component({ selector: "app-root", changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <ul role="tablist"> @for (tab of tabList; let index = $index; track tab.id) { <li role="tab" [attr.id]="tab.id" [attr.tabIndex]="index === activeTabIndex() ? 0 : -1" [attr.aria-selected]="index === activeTabIndex()" [attr.aria-controls]="tab.panelId" (click)="setActiveTabIndex(index)" > {{ tab.label }} </li> } </ul> @for (tab of tabList; let index = $index; track tab.panelId) { <div role="tabpanel" [attr.id]="tab.panelId" [attr.aria-labelledby]="tab.id" [style]=" 'display: ' + (index !== activeTabIndex() ? 'none' : 'block') " > <code> {{ tab.content }} </code> </div> } </div> `,})export class AppComponent { activeTabIndex = signal(0); setActiveTabIndex(val: number) { this.activeTabIndex.set(val); } tabList = [ { id: "javascript-tab", label: "JavaScript", panelId: "javascript-panel", content: `console.log("Hello, world!");`, }, { id: "python-tab", label: "Python", panelId: "python-panel", content: `print("Hello, world!")`, }, ];}
<!-- App.vue --><script setup>import { ref } from "vue";const tabList = [ { id: "javascript-tab", label: "JavaScript", panelId: "javascript-panel", content: `console.log("Hello, world!");`, }, { id: "python-tab", label: "Python", panelId: "python-panel", content: `print("Hello, world!")`, },];const activeTabIndex = ref(0);function setActiveTabIndex(val) { activeTabIndex.value = val;}</script><template> <div> <ul role="tablist"> <li v-for="(tab, index) in tabList" :key="tab.id" role="tab" :id="tab.id" :tabIndex="index === activeTabIndex ? 0 : -1" :aria-selected="index === activeTabIndex" :aria-controls="tab.panelId" @click="setActiveTabIndex(index)" > {{ tab.label }} </li> </ul> <div v-for="(tab, index) in tabList" :key="tab.panelId" role="tabpanel" :id="tab.panelId" :aria-labelledby="tab.id" :style="'display: ' + (index !== activeTabIndex ? 'none' : 'block')" > <code> {{ tab.content }} </code> </div> </div></template>
Tabs Styling
🎉 Tad-whoa. 😵💫
Are we sure this worked?
Well, it's not the prettiest UI visually, but we can verify it's functionality by clicking on the JavaScript
or Python
text in order to show the console.log
or print
statements respectively.
To fix the styling logic, we need to add some styling to the mix...
The required CSS for the tab component
/* index.css */[role="tablist"] { margin: 0; padding: 0; display: flex; gap: 0.25rem;}[role="tab"] { display: inline-block; padding: 1rem; border: solid black; border-width: 2px 2px 0 2px; border-radius: 1rem 1rem 0 0;}[role="tab"]:hover { background: #d3d3d3;}[role="tab"]:active { background: #878787;}[role="tab"][aria-selected="true"] { background: black; color: white;}[role="tabpanel"] { border: solid black; border-width: 2px; padding: 1rem; border-radius: 0 1rem 1rem 1rem;}
And tada - the tabs look correct!
Tabs Interactivity
This looks correct, but we have a huge problem; the tab
component is not keyboard accessible. There's no way for a user to navigate between the different tabs using their keyboard.
To solve this, we'll follow the W3C's ARIA Authoring Practices to add keyboard navigation to our component with the following key bindings:
Key Binding | Action |
---|---|
ArrowRight | Move focus to the next tab. If on the last tab, move focus to the first tab. |
ArrowLeft | Move focus to the previous tab. If on the first tab, move focus to the last tab. |
Home | Move focus to the first tab. |
End | Move focus to the last tab. |
To do this, we need to add a keypress
listener to our tab
elements:
- React
- Angular
- Vue
const App = () => { const [activeTabIndex, _setActiveTabIndex] = useState(0); const setActiveTabIndex = useCallback((indexFn) => { _setActiveTabIndex((v) => { const newIndex = normalizeCount(indexFn(v), tabList.length); const target = document.getElementById(tabList[newIndex].id); target.focus(); return newIndex; }); }, []); const onKeydown = useCallback( (event) => { let preventDefault = false; switch (event.key) { case "ArrowLeft": setActiveTabIndex((v) => v - 1); preventDefault = true; break; case "ArrowRight": setActiveTabIndex((v) => v + 1); preventDefault = true; break; case "Home": setActiveTabIndex((_) => 0); preventDefault = true; break; case "End": setActiveTabIndex((_) => tabList.length - 1); preventDefault = true; break; default: break; } if (preventDefault) { event.stopPropagation(); event.preventDefault(); } }, [setActiveTabIndex], ); return ( <div> <ul role="tablist"> {tabList.map((tab, index) => ( <li key={tab.id} role="tab" id={tab.id} tabIndex={index === activeTabIndex ? 0 : -1} aria-selected={index === activeTabIndex} aria-controls={tab.panelId} onClick={() => setActiveTabIndex((_) => index)} onKeyDown={onKeydown} > {tab.label} </li> ))} </ul> {tabList.map((tab, index) => ( <div key={tab.panelId} role="tabpanel" id={tab.panelId} aria-labelledby={tab.id} style={{ display: index !== activeTabIndex ? "none" : "block" }} > <code>{tab.content}</code> </div> ))} </div> );};function normalizeCount(index, max) { if (index < 0) { return max - 1; } if (index >= max) { return 0; } return index;}
@Component({ selector: "app-root", changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div> <ul role="tablist"> @for (tab of tabList; let index = $index; track tab.id) { <li role="tab" [attr.id]="tab.id" [attr.tabIndex]="index === activeTabIndex() ? 0 : -1" [attr.aria-selected]="index === activeTabIndex()" [attr.aria-controls]="tab.panelId" (click)="setActiveTabIndex(index)" (keydown)="onKeyDown($event)" > {{ tab.label }} </li> } </ul> <!-- ... --> </div> `,})export class AppComponent { activeTabIndex = signal(0); setActiveTabIndex(val: number) { const normalizedIndex = normalizeCount(val, this.tabList.length); this.activeTabIndex.set(normalizedIndex); const target = document.getElementById(this.tabList[normalizedIndex].id); target?.focus(); } onKeyDown(event: KeyboardEvent) { let preventDefault = false; switch (event.key) { case "ArrowLeft": this.setActiveTabIndex(this.activeTabIndex() - 1); preventDefault = true; break; case "ArrowRight": this.setActiveTabIndex(this.activeTabIndex() + 1); preventDefault = true; break; case "Home": this.setActiveTabIndex(0); preventDefault = true; break; case "End": this.setActiveTabIndex(this.tabList.length - 1); preventDefault = true; break; default: break; } if (preventDefault) { event.stopPropagation(); event.preventDefault(); } } // ...}function normalizeCount(index: number, max: number) { if (index < 0) { return max - 1; } if (index >= max) { return 0; } return index;}
<!-- App.vue --><script setup>import { ref } from "vue";// ...const activeTabIndex = ref(0);function setActiveTabIndex(val) { const normalizedIndex = normalizeCount(val, tabList.length); activeTabIndex.value = normalizedIndex; const target = document.getElementById(tabList[normalizedIndex].id); target?.focus();}function onKeyDown(event) { let preventDefault = false; switch (event.key) { case "ArrowLeft": setActiveTabIndex(activeTabIndex.value - 1); preventDefault = true; break; case "ArrowRight": setActiveTabIndex(activeTabIndex.value + 1); preventDefault = true; break; case "Home": setActiveTabIndex(0); preventDefault = true; break; case "End": setActiveTabIndex(tabList.length - 1); preventDefault = true; break; default: break; } if (preventDefault) { event.stopPropagation(); event.preventDefault(); }}function normalizeCount(index, max) { if (index < 0) { return max - 1; } if (index >= max) { return 0; } return index;}</script><template> <div> <ul role="tablist"> <li v-for="(tab, index) in tabList" :key="tab.id" role="tab" :id="tab.id" :tabIndex="index === activeTabIndex ? 0 : -1" :aria-selected="index === activeTabIndex" :aria-controls="tab.panelId" @click="setActiveTabIndex(index)" @keydown="onKeyDown($event)" > {{ tab.label }} </li> </ul> <!-- ... --> </div></template>
Yay, we did it! 🎉 (For real this time.)
- React
- Angular
- Vue
Now these are some tabs we can work with.