0% found this document useful (0 votes)
39 views83 pages

Accessible Components

Uploaded by

bucevaldooo
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
39 views83 pages

Accessible Components

Uploaded by

bucevaldooo
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 83

Accessible Components

By Chris Ferdinandi
Go Make Things, LLC
v1.0.0

Copyright 2022 Chris Ferdinandi and Go Make Things, LLC. All Rights Reserved.
Table of Contents
1. Intro
A quick word about browser compatibility
Using the code in this guide
Technical editing
2. Accessibility and screen readers
Navigating with a keyboard
A quick note about macOS
Screen Readers
Enabling a screen reader
Using a screen reader
CSS-only components
3. HTML Semantics
Role, name, and state
If an element should be interactive, use something focusable
Buttons and links do different things
Focus Management
ARIA
4. Hiding UI content
The [hidden] attribute, and the display and visibility properties
The [aria-hidden] attribute
The [aria-label] attribute
5. Announcing dynamic content changes
The [aria-live] attribute
The [aria-atomic] attribute
ARIA live region roles
6. Button state
Wrong ways to show active state
The accessible way to show active state
7. Interactive components
A quick aside about the [aria-controls] attribute
8. Disclosure Component
Accessibility Requirements
The Template
Setting up the DOM
Toggling content visibility
9. HTML-Only Disclosure Component
Styling details and summary
Detecting when the content expands or collapses
10. Accordion
Accessibility Requirements
The Template
Setting up the DOM
Toggling content visibility
Styling the buttons
11. Tabs
Accessibility Requirements
The Template
Setting up the DOM
Toggling content visibility when a tab is clicked
Showing the active tab
Styling the buttons
Navigating tabs with arrow keys
12. Notification
Accessibility Requirements
The Template
Creating a utility function
Styling the notifications
Announcing the notification
Automatically removing notifications after a few moments
Dismissing a notification
13. About the Author
Intro
In this guide, you’ll learn:

What assistive technology is


How to enable and use a screen reader
The basics of navigating the web with a keyboard
The dangers of CSS-only interactive components
How to hide content, and accessibility considerations when doing so
How to announce dynamic changes to the UI
How to indicate button state
How to build four of the most common interactive components

You can download all of the source code at


https://github.com/cferdinandi/accessible-components-source-code.

A quick word about browser compatibility


This guide focuses on methods and APIs that are supported in all modern browsers.
That means the latest versions of Edge, Chrome, Firefox, Opera, Safari, and the
latest mobile browsers for Android and iOS.

Using the code in this guide


Unless otherwise noted, all of the code in this book is free to use under the MIT
license. You can view of copy of the license at https://gomakethings.com/mit.

Let’s get started!

Technical editing
A technical review of this book was done by the amazing Léonie Watson.
Léonie is a director at TetraLogical, member of the W3C Advisory Board, co-Chair
of the W3C Web Applications and HTML working groups, and member of the BIMA
Inclusive Design Council.

I’m incredibly grateful for her help and guidance in ensuring that the advice and
recommendations in this guide are good!
Accessibility and screen readers
Accessibility is sometimes abbreviated with the numeronym A11Y, with 11
representing the number of letters between the A and Y.

For web developers, the field of accessibility is focused on providing people with
equal access to the things we make, regardless of any physical or neurological
disabilities.

That includes both temporary disabilities (such as losing mobility after breaking an
arm) and long-term or permanent disabilities. It includes physical disabilities, such
as visual impairment or a neuromuscular condition, as well as cognitive
impairments.

As you can imagine, the field of accessibility covers a wide-range of topics, from
HTML structure to color choices to copywriting and more.

This guide is specifically focused on a subset of A11Y: building interactive


JavaScript components that can be used by as many people as possible.

Navigating with a keyboard


People with visual impairments often navigate the web with just a keyboard instead
of using a mouse. People with neuromuscular conditions sometimes use only a
keyboard as well, as those certain conditions can make using a mouse difficult.

Using the tab key, you can jump from one focusable element (such as links and
form fields) to another. Using the enter or return keys, you can activate a link or
submit a form. Using the space bar, you press a button or scroll down on a page.

When building things for the web, it’s important to ensure that the things can be
used with just a keyboard.

A quick note about macOS


On macOS, keyboard focus navigation is off by default. Chromium browsers (like
Chrome and Edge) automatically support keyboard navigation anyways, but Firefox
and Safari honor the system preferences.

To enable website keyboard navigation on macOS…

1. Open System Preferences.


2. Select the “Keyboard” tab.
3. Select the “Shortcuts” tab.
4. Check the box labeled: “Use keyboard navigation to move focus between
controls.”

Screen Readers
Users who are visually impaired may use a piece of software called a screen reader
to navigate the web.

Screen readers announce the text on a page out loud so that someone who is
visually impaired can use it. They also typically communicate additional
information about the page, such as the heading structure and other landmarks.

Both macOS and Windows 10 and up include free screen reader software. Users on
Linux or older versions of Windows have free third-party options to choose from.

Enabling a screen reader

On macOS

Open System Preferences, click the Accessibility tab, then click


Voice Over. Check the Enable Voiceover box. You can alternatively use
the Command + F5 keyboard shortcut.

On Windows 10

Click Start, then Settings. Click Ease of Access. Toggle the Narrator
button to On. You can alternatively use the Win + Control + Enter
keyboard shortcut.

On all Windows devices


You can install NVDA for free on Windows, including versions older than
Windows 10 where Narrator is not a good option.

On Linux

You can install Orca for free.

Using a screen reader

In order to build accessible web experiences, it’s important to understand how a


screen reader announces the content of what you build.

If you’ve never used one before, using one to navigate through your website can be
very enlightening. Open up a site you’ve built, turn on a screen reader, and using
either the Control + Alt + Right Arrow keys on macOS or Down Arrow key
on Windows to navigate through the site’s content.

To see a screen reader in action, you can also watch this video from TetraLogical.

Note: using a screen reader for the first time (and second, and third, and…) is an
awkward, clumsy experience. You don’t have to be an expert. The point right now is to
get a taste for what your website is like for people who can’t see the layout or changes to
it.

CSS-only components
People sometimes build interactive components using only CSS. These are almost
always inaccessible for people who use screen readers and other assistive
technology.

As we’ll see in the coming sections, screen readers expect and require certain
attributes and properties on HTML elements to understand what’s happening in
the UI and communicate those changes to the people who use them. For interactive
components, the values of those properties often change dynamically.

CSS is incredibly powerful, and I love to replace JavaScript with CSS when I can.

But for interactive components, CSS cannot update the attribute values that screen
readers rely on to understand what’s happening in the UI. Additionally, many CSS-
only components assume the use of a mouse, and don’t work (or don’t work as
expected) with keyboard navigation.

Screen readers and other assistive tech not only work just fine with JavaScript, but
often require JS to work properly with interactive components.
HTML Semantics
The HTML elements that you use often convey information to people who use
screen readers, and provide critical functionality to people who navigate the web
with a keyboard.

Role, name, and state


Properly coded HTML elements provided three pieces of information to screen
readers: role, name, and state.

Role. Communicate the intent or purpose of the element. For example, a link
element (a) has a role of link. An h2 element has a role of heading level 2.
Name. The text that’s read aloud for that element or piece of content. For a
link or heading, for example, that would typically be the text inside the
element.
State. The current state of the element. Some elements, like headings, are
generally stateless. However, a link might have a state of visited. A button
might have a state of pressed.

While HTML elements often include this information by default, we as developers


can break them with the choices we make. And sometimes, additional attributes
and properties are needed to bridge the gap between HTML and screen readers.

If an element should be interactive, use something


focusable
People who navigate the web with a keyboard can jump from one focusable element
to the next by hitting the Tab key on their keyboard. They can then interact with
that element with the Enter or Return key or the Space bar.

Not all elements are focusable.


Developers often use a div or span elements with a click event listener. These
should be button or a elements, which can be focused. With a div, a screen reader
user might not know the element is interactive, and a keyboard user couldn’t
interact with it at all (without some additional hacking).

.btn {
background-color: #e5e5e5;
border: 1px solid #808080;
border-radius: 0.25em;
color: #272727;
font: inherit;
margin-right: 0.5em;
padding: 0.5em 0.85em;
}

.btn:hover {
background-color: #0088cc;
border-color: #0088cc;
color: #ffffff;
}

<!-- This is good -->


<button class="btn">Activate me</button>

<!--
This is bad
A span element cannot be focused or accessed with a
keyboard,
and it will not be identified as a link or button by
screen readers
-->
<span class="btn">Activate me, too</span>

Buttons and links do different things


Even if you use a focusable element, it might not be the right one. I often see
people use links as buttons.

<!-- Avoid this -->


<a class="btn" href="#">Click Me</a>

Links and buttons convey unique semantic meaning to screen readers.

A link implies that clicking it will take you to a different location—either another
page, or a different spot on the current one. Buttons imply interactivity—showing
and hiding content, submitting a form, and so on.

<!-- This is a link. It takes you somewhere. -->


<a href="/merlin">Learn about Merlin</a>

<!-- This is a button. It adds interactivity to the current


page. -->
<button>Cast a Spell</button>

Marcy Sutton wrote a fantastic article about this.

Sometimes, you might have a valid link that you progressively enhance into a
button after your JavaScript loads. In that case, you can add [role="button"] to
the link element with your JavaScript.

This is a common pattern with progressively enhanced links and async HTML
loading.

By default, you might have a link that points to another webpage. When your
JavaScript loads, you may instead have it fetch HTML and load it into the current
page instead.

In that case, you would add [role="button"] to the link in the JavaScript file.
You would also need to write some JavaScript to trigger a click event when the
Space bar is prssed with the element is in focus.
<!-- Default -->
<a id="js-async" href="/all-about-merlin">Learn more about
Merlin</a>

<!-- After JavaScript Loads -->


<a role="button" id="js-async" href="/all-about-
merlin">Learn more about Merlin</a>

// Add the [role="button"] attribute to the link


let link = document.querySelector('#js-async');
link.setAttribute('role', 'button');

// Fire click events when space bar is pressed on a link


with [role="button"]
document.addEventListener('keyup', function (event) {

// Only run when Space bar is pressed and active


element has [role="button"] attribute
if (event.code !== 'Space' ||
!document.activeElement.matches('a[role="button"]'))
return;

// Fire click event on active element


let click = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window
});

// Dispatch event
document.activeElement.dispatchEvent(click);

});

But as a general rule, if it takes you to another page, use a link. If its just for on-
page interactivity, use a button.
Focus Management
Focus order is the order that the different focusable elements on a page will come
into focus if you attempt to navigate to them by pressing the Tab key.

By default, focus order matches the order in which elements appear on a page.

<button>This will focus first</button>


<button>Then this will</button>
<button>Finally, this will focus last</button>

You can change the order that elements come into focus with thetabindex
property.

A value of 0 for tabindex is the default innate value of focusable elements. You
can use other values to change the tab order of elements on the page.

You should almost never do this!

<!-- Don't do this! -->


<button tabindex="3">This will focus last</button>
<button tabindex="1">This will focus first</button>
<button tabindex="2">This will focus second</button>

That said, there are a few specific occasions where modifying focus behavioris
required.

For certain interactive components, normal focus order should be removed and
replaced with other keyboard interactions. You might also have an element that’s
not focusable by default, but that you need to bring into focus with JavaScript.

In both of these situations, you can apply a tabindex of -1.

This removes the element from the focus order entirely (if it was focusable
already), and allows you to focus it with JavaScript (if it wasn’t already focusable).
<button tabindex="-1">This will be skipped in the focus
order</button>
<div id="app" tabindex="-1">This isn't a focusable element,
but can now be focused with JavaScript.</div>

You can shift focus to an element using the Element.focus() method.

let app = document.querySelector('#app');


app.focus();

ARIA
ARIA (Accessible Rich Internet Applications) is a set of attributes that provide
additional details to screen readers when HTML alone doesn’t convey enough
information.

The ARIA spec includes the [role] attribute, which can be used to convey or
modify the role of an element, and includes some values beyond native HTML.
ARIA also includes special attributes, prefixed with [aria-*].

Generally speaking, you should only use ARIA when the semantics of the
HTML element alone are not enough to convey all of the information about
the UI.

For example, you do not need to to include the [role="button"] attribute with a
button element. The semantics of the element already convey that it’s abutton
to screen readers.

<!-- Don't do this -->


<button role="button">Sign Up</button>

<!-- The [aria-pressed] attribute conveys information HTML


alone does not -->
<button aria-pressed="false">Play/Pause</button>
We’ll explore specific ARIA attributes, when they’re needed, and what they do,
throughout the rest of this guide.
Hiding UI content
There are a lot of different ways to hide content in a UI. Screen readers handle
different approaches in different ways.

The [hidden] attribute, and the display and visibility


properties
The HTML [hidden] attribute can be used to hide an element in the UI, and
removes it from the layout. It’s like the element doesn’t exist at all.

Content inside the element will also be ignored by screen readers.

<div hidden>
Nothing inside this element will be displayed in the
UI.
It also won't be read aloud by screen readers.
</div>

Similarly, the display: none CSS property hides an element in the UI, and
prevents screen readers for reading it.

.top-secret {
display: none;
}

<div class="top-secret">
Nothing inside this element will be displayed in the UI
or read aloud, either.
</div>

The visibility: hidden property hides an element visually, but keeps the
element in the layout. Screen readers will read content inside an element hidden
this way.
.invisible {
visibility: hidden;
}

<div class="invisible">
Nothing inside this element will be displayed in the
UI, but it still affects the layout and gets read by screen
readers.
</div>

The [aria-hidden] attribute


The [aria-hidden] attribute tells screen readers whether or not they should pay
attention to the content contained inside an element. A value of true means
“ignore the content”, and a value of false is the same as not having the attribute
at all.

Imagine that you have a button. To make it a bit more visually interesting, you add
some emoji to it.

<button>★☻✓ Click to Join</button>

A screen reader might read that button text as…

black star, filled smiley face, checkmark, click to join

While a sighted user might be able to recognize that the emoji are purely
decorative, a visitor using a screen reader is presented with nonsense.

If we wrap the emoji in aspan with the [aria-hidden="true"] attribute, now


screen readers will read the button text as just “Click to Join,” ignoring the emoji.
<button>
<span aria-hidden="true">★☻✓</span>
Click to Join
</button>

The [aria-label] attribute


The [aria-label] attribute lets you add what’s called an accessible label. This is
text that’s read aloud by screen readers, but is not visually exposed to users.

For example, imagine you have a button that contains an SVG icon of a cloud with
an arrow pointing down out of it, but no text. In this app, it’s supposed to mean
“download.”

<button>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16
16">
<path ...>
</svg>
</button>

If you’re not visually impaired, you might be able to figure that out. If you rely on a
screen reader, though, this button will announce, “button.”

If we add an [aria-label] with a value of Download to the button, screen


readers will instead announce, “Download.”

<button aria-label="Download">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16
16">
<path ...>
</svg>
</button>
The [aria-label] text is read instead of any content inside the element,
including otherwise accessible text.

In addition to icons, it can be useful when the context of a link or button might be
apparent to sighted users, but confusing for visually impaired users.

For example, a sighted user might be able to infer that a “read more” link is for the
article before it, while a screen reader user tabbing through links might not.

The link below would be announced as “Read more about pirates,” instead of “Read
more…”.

<a href="link-to-article.html" aria-label="Read more about


pirates">
Read more...
</a>
Announcing dynamic content changes
In JavaScript apps, content in the UI sometimes changes dynamically. How do
people who use screen readers know when that the content has changed?

Generally speaking, screen readers will not notice or announce those changes
unless you tell them that they need to.

And for that, we need ARIA live regions.

The [aria-live] attribute


The [aria-live] attribute tells screen readers that the content in a particular
element is going to change dynamically, and that they should pay attention to it
and announce those changes.

It’s value can be set to off (the same as not using it at all),assertive (in which
screen readers interrupt user actions to announce changes), and polite (which
tells the screen reader to wait until the user is done to announce updates).

In most cases, you should use polite. Choose assertive when the
announcement should interrupt whatever the user is doing (use with caution).

<!-- Changes to this element's text will get announced


because the #app element has an [aria-live] attribute -->
<div id="app" aria-live="polite">The content of this
element is going to change after 5 seconds.</div>

let app = document.querySelector('#app');


setTimeout(function () {
app.textContent = 'Whoa, this is new!';
}, 5000);

Note: in order to announce changes, the ARIA live region has to be in the UI before its
content is changed.
The [aria-atomic] attribute
The [aria-atomic] property tells screen readers whether or not to announce the
entire ARIA live region even when only a portion of it has changed.

It will announce all changes when given a value oftrue, and only the changed
content when given a value of false (the same as omitting it altogether).

In this example, only the paragraph text is announced, since that’s the only thing
that changed.

<div id="app" aria-live="polite">


<h2>Hello, world</h2>
<p>How are you today?</p>
</div>

let p = document.querySelector('p');
setTimeout(function () {
p.textContent = 'Good evening!';
}, 5000);

If we add [aria-atomic="true"], however, both the heading and paragraph are


announced when either one changes.

<div id="app" aria-live="polite" aria-atomic="true">


<h2>Hello, world</h2>
<p>How are you today?</p>
</div>

let p = document.querySelector('p');
setTimeout(function () {
p.textContent = 'Good evening!';
}, 5000);
ARIA live region roles
In addition to the [aria-live] attribute, there are specific role values that act
as live regions, and will cause screen readers to announce when content in them is
updated.

The [role="status"] attribute can be used for non-critical status updates. It


behaves the same way an element with [aria-live="polite"] would.

You might use it, for example, to announce when some API content has been
loaded into the UI after a user interaction.

<div role="status"></div>

let msg = document.querySelector('[role="status"]');


msg.textContent = 'The content was loaded.';

The [role="alert"] attribute is used for time-sensitive information, and is


equivalent to having an element with the [aria-live="assertive"] and
[aria-atomic="true"] attributes.

You might use it to, for example, let a user know that their data didn’t save.

<div role="alert"></div>

let msg = document.querySelector('[role="alert"]');


msg.textContent = 'Unable to save your update. Sorry!';
Button state
Huge shoutout to Sarah Higley for her awesome article on button state, which heavily
informed this lesson.

In apps, it’s common to have buttons that have an on/off or pressed/not pressed
state.

For example, imagine a like/favorite button.

<button class="fave" aria-label="Favorite">❤</button>

Maybe you have a few simple styles associated with the.fave class, like this.

.fave {
background: transparent;
border: 0;
font-size: 2em;
}

When someone clicks the button, you want to show that it’s “active.”

Wrong ways to show active state


One common practice is to use “active state” classes, like.is-active, to modify
the button’s appearance.

<button class="fave is-active" aria-


label="Favorite">❤</button>

.fave.is-active {
color: red;
}
While this changes the visual appearance of the button, it does not convey any
information about the new state to screen reader users.

You might think you can solve this by changing the [aria-label] to say
Favorited or something similar.

<button class="fave is-active" aria-


label="Favorited">❤</button>

Unfortunately, changes to the [aria-label] text are not announced by most


screen reader/browser combinations (and even if they were, an elements label is
different from its state).

The accessible way to show active state


To solve this issue, you can use the[aria-pressed] attribute.

This attribute lets screen readers know that a button has “state.” When it has a
value of false, the button is not pressed. When it has a value oftrue, it is.

<!-- This button is NOT active -->


<button class="fave" aria-label="Favorite" aria-
pressed="false">❤</button>

<!-- This button IS -->


<button class="fave" aria-label="Favorite" aria-
pressed="true">❤</button>

You can change the attribute value using the setAttribute() method.

Don’t remove the attribute if the button is inactive. Toggle it fromtrue to false
// The button is active
btn.setAttribute('aria-pressed', true);

// The button is inactive


btn.setAttribute('aria-pressed', false);

You can even hook into it for styling purposes.

.fave[aria-pressed="true"] {
color: red;
}

With this approach, you should not change the[aria-label] if you’ve used one.

The [aria-pressed] attribute is what conveys the important information about


the button state.
Interactive components
To make this all tangible, let’s work on a project together. We’re going to build a
collection of accessible components.

Many common interactive components have documented expectations around the


types of elements that should be used, ARIA roles and attributes, and keyboard
interactions.

The World Wide Web Consortium (W3C) maintains documentation on common


components and patterns. It can be difficult to read at times, though it is getting
better. Dave Rupert created A11Y Nutrition Cards, short summaries on some of the
more common components, from the W3C docs.

For the rest of this guide, we’re going to look at some common patterns, and create
simple, accessible components that you can use as a starting point for your own
projects.

Here are the components we’ll be building.

Disclosure (show/hide)
Accordion
Tabs
Alert

The starter templates and complete project code are included in the source code on
GitHub.

A quick aside about the [aria-controls] attribute


In the ARIA authoring practices guide, several of the components we’ll be looking
at mention the [aria-controls] attribute.

In theory, it’s supposed to create a link between an interactive element and the
content that it controls. In reality, JAWS was the only screen reader to ever
implement it, and the associated announcements it makes are clunky.

Heydon Pickering details this in a bit more detail on this website.


The recommendation I’ve heard from multiple accessibility professionals is not to
use the attribute. As a result, we will not be including the [aria-controls]
attribute with the components that we build in this guide.
Disclosure Component
A disclosure component is often referred to as a show/hide or expand-and-collapse
component. The user clicks something to reveal some content, and clicks it again to
hide it.

Accessibility Requirements
We’ll refer to the thing that causes the content to show or hide as thetrigger, and
the content it reveals as the content.

The trigger should be a button.


The trigger should have an [aria-expanded] attribute. It’s value should be
true when the content is visible, and false when it’s hidden.
Pressing the Enter or Space keys while the trigger has focus should show or
hide the content.

The Template
For this component, we’ll start off with a button element already in the UI, but
hidden with the [hidden] attribute.

Our content is wrapped in a div element with an ID of #content. It’s visible by


default.

The content is visible by default, and the button to toggle it is hidden. We’re going
to progressively enhance our content into a disclosure after our JavaScript loads.

We’ll give our button a [data-disclosure] attribute with a value of #content.


We can hook into this with our JavaScript later.
<p>
<button data-disclosure="#content" hidden>
Show Content
</button>
</p>

<div id="content">
<p>Now you see me, now you don't!</p>
</div>

Setting up the DOM


The first thing we want to do after our JavaScript loads is add any required ARIA
attributes, show the button, and hide the content.

Let’s create a setupDOM() function to help us do that.

/**
* Show buttons and hide content on page load
*/
function setupDOM () {
// Do stuff...
}

In the function, we’ll use the document.querySelectorAll() method to get


all elements in the DOM that have the [data-disclosure] attribute, and assign
them to the disclosures variable.

Then, we’ll use a for...of loop to loop through each one.


/**
* Show buttons and hide content on page load
*/
function setupDOM () {

// Get all disclosure buttons


let disclosures = document.querySelectorAll('[data-
disclosure]');

// Loop through each disclosure


for (let disclosure of disclosures) {
// ...
}
}

Inside the loop, we’ll use the Element.getAttribute() method to get the ID
selector value from the [data-disclosure] attribute, and pass it into the
document.querySelector() method to get the matching content.

If none is found, we’ll use thecontinue operator to skip to the next disclosure.

// Loop through each disclosure


for (let disclosure of disclosures) {

// Get the associated content


// If there isn't any, bail
let content =
document.querySelector(disclosure.getAttribute('data-
disclosure'));
if (!content) continue;

Otherwise, we’ll use the Element.removeAttribute() method to remove the


[hidden] attribute from the disclosure button, and the
Element.setAttribute() method to add it to the content.
We’ll also add an [aria-expanded] attribute with a value of false to the
disclosure element.

// Loop through each disclosure


for (let disclosure of disclosures) {

// Get the associated content


// If there isn't any, bail
let content =
document.querySelector(disclosure.getAttribute('data-
disclosure'));
if (!content) continue;

// Show the button, hide the content


disclosure.removeAttribute('hidden');
content.setAttribute('hidden', '');

// Add ARIA
disclosure.setAttribute('aria-expanded', false);

Finally, we’ll run the setupDOM() method to initialize our script when the page
loads.

// Initialize the script


setupDOM();

Toggling content visibility


Our content should be shown or hidden whenever the button is clicked. It should
also show or hide whenever it has focus and the Enter or Space keys are pressed.

One huge benefit of using an actual button element for our trigger is that it will
fire a click event whenever the Enter or Space key are pressed while it has
focus, automatically and by default. It’s part of the semantics of the element.
This means that we can use a single click event listener with the
Element.addEventListener() method instead of multiple listeners.

Since we may have more than one disclosure on the page, we’ll use event
delegation and listen for all clicks on the document. We’ll pass in a
clickHandler() function as our callback.

// Initialize the script


setupDOM();
document.addEventListener('click', clickHandler);

In the clickHandler() function, we’ll use the Element.getAttribute()


method to get the value of the [data-disclosure] attribute from the
event.target, the element that was clicked.

If the attribute doesn’t exist, we’ll use thereturn operator to end our callback
function immediately.

/**
* Show or hide content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on disclosure buttons


let target = event.target.getAttribute('data-
disclosure');
if (!target) return;

Otherwise, we’ll pass the target value into the document.querySelector()


method to get the associated content. If no matching content is found, we’ll
again use the return operator to end the callback function.
/**
* Show or hide content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on disclosure buttons


let target = event.target.getAttribute('data-
disclosure');
if (!target) return;

// Get the content associated with the disclosure


let content = document.querySelector(target);
if (!content) return;

If the event.target, the clicked button, has an [aria-expanded] value of


true, it’s already expanded and we want to hide the content. We’ll set [aria-
expanded] to false and add the [hidden] attribute to the content.

Otherwise, we want to show it. We’ll set [aria-expanded] to true, and remove
the [hidden] attribute from the content.
/**
* Show or hide content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on disclosure buttons


let target = event.target.getAttribute('data-
disclosure');
if (!target) return;

// Get the content associated with the disclosure


let content = document.querySelector(target);
if (!content) return;

// If the content is expanded, hide it


// Otherwise, show it
if (event.target.getAttribute('aria-expanded') ===
'true') {
event.target.setAttribute('aria-expanded', false);
content.setAttribute('hidden', '');
} else {
event.target.setAttribute('aria-expanded', true);
content.removeAttribute('hidden');
}

Now, whenever someone clicks a disclosure button (or presses the Enter or Space
keys while it has focus), our content will show or hide.
HTML-Only Disclosure Component
You can use the details and summary elements to create HTML-only disclosure
components—no JavaScript required!

Put your entire disclosure content inside the details element. The heading that
should act as a toggle goes inside the nested summary element.

When expanded, the details element has the open attribute. You can manually
add it to the element to make your disclosure expanded by default.

<details>
<summary>The toggle</summary>
The content.
</details>

<details open>
<summary>Another toggle</summary>
Expanded by default.
</details>

Styling details and summary


You can style the details and summary elements with CSS. In this example, I’ve
added some spacing, and expanded elements have a light-gray background color.
details {
margin-bottom: 2em;
}

details[open] {
background-color: #f7f7f7;
padding: 0.5em;
}

summary {
margin-bottom: 0.5em;
}

You can also customize the icon that’s show when the component is expanded or
collapsed using the ::marker pseudo-selector (and ::-webkit-details-
marker for webkit/blink browsers).

In this example, I’m showing a plus symbol (+ ) when the content is collapse, and a
minus symbol (-) when it’s expanded.

/**
* 1. Styling for Firefox and other non-webkit/blink
browsers
* 2. Styling for webkit and blink
*/
summary::marker, /* 1 */
summary::-webkit-details-marker { /* 2 */
display: none;
content: "+ ";
}

[open] > summary::marker, /* 1 */


[open] > summary::-webkit-details-marker { /* 2 */
display: none;
content: "– ";
}
Detecting when the content expands or collapses
You can detect when a details/summary component is expanded or collapsed
using the toggle event.

By default, this event does not bubble, and has to be attached directly to the
details component. You can use event delegation by setting the useCapture
property to true.

// Listen for open/close events


// Requires use capture when paired with event delegation
document.addEventListener('toggle', function (event) {

// The disclosure that was toggled


let disclosure = event.target;
console.log(disclosure);

}, true);
Accordion
An accordion component contains a set of headings (and sometimes text snippets)
that can be clicked to reveal additional content. Unlike a disclosure, they typically
contain groups of hidden content, with the header acting as the trigger.

Accessibility Requirements
We’ll refer to the thing that causes the content to show or hide as thetrigger, and
the content it reveals as the content.

The trigger should be a button wrapped in a heading (h1 through h6).


The trigger should have an [aria-expanded] attribute. It’s value should be
true when the content is visible, and false when it’s hidden.
Pressing the Enter or Space keys while the trigger has focus should show or
hide the content.

The Template
For this component, we’ll start off with a collection of headings and content.

Each piece of content has an ID. Each heading has a[data-accordion] attribute,
with a value equal to the ID selector for the content it should toggle the visibility
of.

The content is visible by default. We’re going to progressively enhance our HTML
into an accordion and hide the content after our JavaScript loads.

<h2 data-accordion="#yo-ho-ho">Yo, ho ho!</h2>


<div id="yo-ho-ho">Yo, ho ho and a bottle of rum!</div>

<h2 data-accordion="#ahoy-there">Ahoy, there!</h2>


<div id="ahoy-there">Ahoy there, matey!</div>
Setting up the DOM
The first thing we want to do after our JavaScript loads is wrap the text for each
heading in a button, add any required ARIA attributes, and hide the content.

Let’s create a setupDOM() function to help us do that.

/**
* Add buttons and hide content on page load
*/
function setupDOM () {
// Do stuff...
}

In the function, we’ll use the document.querySelectorAll() method to get


all elements in the DOM that have the [data-accordion] attribute, and assign
them to the headings variable.

Then, we’ll use a for...of loop to loop through each one.

/**
* Show buttons and hide content on page load
*/
function setupDOM () {

// Get all accordion headings


let headings = document.querySelectorAll('[data-
accordion]');

// Wrap content in a button


for (let heading of headings) {
// ...
}
}
Inside the loop, we’ll use the Element.getAttribute() method to get the ID
selector value from the [data-accordion] attribute, and pass it into the
document.querySelector() method to get the matching content.

If none is found, we’ll use thecontinue operator to skip to the next heading.

// Wrap content in a button


for (let heading of headings) {

// Get the matching content


// If there isn't any, skip
let content =
document.querySelector(heading.getAttribute('data-
accordion'));
if (!content) continue;

Otherwise, we’ll use the document.createElement() method to create a


button element. Then, we’ll use the Element.innerHTML property to get the
heading content and add it to the btn.

// Wrap content in a button


for (let heading of headings) {

// Get the matching content


// If there isn't any, skip
let content =
document.querySelector(heading.getAttribute('data-
accordion'));
if (!content) continue;

// Create a button, and copy heading content into it


let btn = document.createElement('button');
btn.innerHTML = heading.innerHTML;

}
Next, we’ll again use the Element.innerHTML property to wipe out all of the
HTML inside the heading. Then, we’ll use the Element.append() method to
inject the btn into the heading.

// Wrap content in a button


for (let heading of headings) {

// ...

// Create a button, and copy heading content into it


let btn = document.createElement('button');
btn.innerHTML = heading.innerHTML;

// Wipe the heading content, and replace it with the


button
heading.innerHTML = '';
heading.append(btn);

We’ll also use the Element.setAttribute() method to add the [hidden]


property to the content to hide it in the UI.

Then, we’ll add an [aria-expanded] property with a value of false to the btn
element.
// Wrap content in a button
for (let heading of headings) {

// ...

// Wipe the heading content, and replace it with the


button
heading.innerHTML = '';
heading.append(btn);

// Hide the content


content.setAttribute('hidden', '');

// Add ARIA
btn.setAttribute('aria-expanded', false);

Finally, we’ll run the setupDOM() method to initialize our script when the page
loads.

// Initialize the script


setupDOM();

Toggling content visibility


Our content should be shown or hidden whenever the button inside the heading is
clicked. It should also show or hide whenever it has focus and the Enter or Space
keys are pressed.

Just like with the disclosure component, one huge benefit of using an actual
button element for our trigger is that it will fire aclick event whenever the
Enter or Space key are pressed while it has focus, automatically and by default.
It’s part of the semantics of the element.

This means that we can use a single click event listener with the
Element.addEventListener() method instead of multiple listeners.

Since we’ll have multiple headings in an accordion, we’ll again use event
delegation and listen for all clicks on the document. We’ll pass in a
clickHandler() function as our callback.

// Initialize the script


setupDOM();
document.addEventListener('click', clickHandler);

In the clickHandler() function, we’ll use the Element.closest() method to


check if the clicked element is inside an element with the [data-accordion]
attribute, and assign that parent element to the accordion variable.

If no matching parent element is found, we’ll use thereturn operator to end our
callback function immediately.

/**
* Show or hide content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on accordion buttons


let accordion = event.target.closest('[data-
accordion]');
if (!accordion) return;

Otherwise, we’ll use the Element.getAttribute() method to get the content


ID from the [data-accordion] attribute and pass it into the
document.querySelector() method.

We’ll assign the returned element to the content variable. If no content is


found, we’ll again use the return operator to end the callback function.
/**
* Show or hide content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on accordion buttons


let accordion = event.target.closest('[data-
accordion]');
if (!accordion) return;

// Get the content associated with the accordion


let content =
document.querySelector(accordion.getAttribute('data-
accordion'));
if (!content) return;

If the event.target, the clicked button, has an [aria-expanded] value of


true, it’s already expanded and we want to hide the content. We’ll set [aria-
expanded] to false and add the [hidden] attribute to the content.

Otherwise, we want to show it. We’ll set [aria-expanded] to true, and remove
the [hidden] attribute from the content.
/**
* Show or hide content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on accordion buttons


let accordion = event.target.closest('[data-
accordion]');
if (!accordion) return;

// Get the content associated with the accordion


let content =
document.querySelector(accordion.getAttribute('data-
accordion'));
if (!content) return;

// If the content is expanded, hide it


// Otherwise, show it
if (event.target.getAttribute('aria-expanded') ===
'true') {
event.target.setAttribute('aria-expanded', false);
content.setAttribute('hidden', '');
} else {
event.target.setAttribute('aria-expanded', true);
content.removeAttribute('hidden');
}

Now, whenever someone clicks an accordion button (or presses the Enter or
Space keys while it has focus), our content will show or hide.

Styling the buttons


Using a button element satisfies various accessibility requirements for our
accordion, but we don’t necessarily want our headings to look like buttons visually.

We can use CSS to target button elements inside headings with the [data-
accordion] attribute. We’ll remove any border and background, make the
font match the parent heading, and remove any margin and padding. We’ll also
align the text to the left, and have the button fill the full width of heading.

/**
* Style the accordion buttons to look like headers
*/
[data-accordion] > button {
background: transparent;
border: none;
display: block;
font: inherit;
margin: 0;
padding: 0;
text-align: left;
width: 100%;
}

We can also use CSS to display different icons when the accordion is expanded or
collapsed by targeting the [aria-expanded] attribute on the button.

We’ll use the ::after pseudo-selector with the content property to display a
dash (–) or plus sign (+).
/**
* Show expand/collapse icons
*/
[data-accordion] > button[aria-expanded="true"]::after {
content: " –";
}

[data-accordion] > button[aria-expanded="false"]::after {


content: " +";
}

Now, we have a simple, accessible accordion component progressively enhanced


from a collection of headings and content.
Tabs
A tab component is a collection of content whose visibility is controlled by a list of
tabs. Only one content section can be visible at a time, and there is always a visible
content section (as in, they can’t all be hidden).

Accessibility Requirements
We’ll refer to the thing that causes the content to show or hide as thetrigger, and
the content it reveals as the content.

Each trigger should have a [role] attribute with a value of tab.


The parent element the list of tabs are contained in should have a[role]
attribute with a value of tablist.
If the parent element is a list element (ul or ol), each of the list items (li)
should have a [role] attribute with a value of presentation (this lets
screen readers know that the element is semantically not a list item any more).
Each trigger should have an [aria-selected] attribute. It’s value should be
true when the content associated with that tab is visible, andfalse when it’s
hidden.
The content for each tab should have a [role] attribute with a value of
tabpanel.
The content for each tab should have an [aria-labelledby] attributes,
with the ID of the trigger that controls it as its value.
Tab key does not shift focus to inactive triggers. Only the currently active
trigger can receive focus with the Tab key.
When the active trigger has focus, the ArrowLeft and ArrowRight keys shift
focus to the previous or next trigger, respectively.
A trigger can be activated automatically when it receives focus, or manually
with the Enter or Space keys when it has focus. Automatic activation is the
preference, and you should use one pattern or the other (not both).
For both automatic and manual tab triggers, clicking on a trigger should
activate it and show its content.

As you can see, the tab component has a lot more requirements and considerations
than some of the other components we’ve looked at.
The Template
For this component, we’ll start off with a list of anchor links, and a set of anchored
content.

Each piece of content has an ID. The list of anchor links has a[data-tabs]
attribute, indicating that it should be progressively enhanced into tabs after the
JavaScript loads.

<ul data-tabs>
<li><a href="#wizard">Wizard</a></li>
<li><a href="#sorcerer">Sorcerer</a></li>
<li><a href="#druid">Druid</a></li>
</ul>

<div id="wizard">
<!-- ... -->
</div>

<div id="sorcerer">
<!-- ... -->
</div>

<div id="druid">
<!-- ... -->
</div>

Setting up the DOM


The first thing we want to do after our JavaScript loads is add the various roles and
attributes to the triggers and content, and hide all of the content sections (except for
the active one).
Let’s create a setupDOM() function to help us do that.

/**
* Add ARIA attributes and hide content on page load
*/
function setupDOM () {
// Do stuff...
}

In the function, we’ll use the document.querySelector() method to get the


list with the [data-tabs] attribute, and assign it to the tabList variable.

If no tabList is found, we’ll use the return operator to end the function early.

/**
* Add ARIA attributes and hide content on page load
*/
function setupDOM () {

// Get the [data-tabs] element


let tabList = document.querySelector('[data-tabs]');
if (!tabList) return;

Next, we’ll use the Element.querySelectorAll() method to get all of the li


and a elements in the tabList, and assign them to the tabs and links variables,
respectively.
/**
* Add ARIA attributes and hide content on page load
*/
function setupDOM () {

// Get the [data-tabs] element


let tabList = document.querySelector('[data-tabs]');
if (!tabList) return;

// Get the list items and links


let listItems = tabList.querySelectorAll('li');
let links = tabList.querySelectorAll('a');

We’ll use the Element.setAttribute() method to add a [role] of tablist


to the tabList element.

Then, we’ll use a for...of loop to loop through each of the tabs, and add a
[role] of presentation.
/**
* Add ARIA attributes and hide content on page load
*/
function setupDOM () {

// ...

// Get the list items and links


let listItems = tabList.querySelectorAll('li');
let links = tabList.querySelectorAll('a');

// Add ARIA to list


tabList.setAttribute('role', 'tablist');

// Add ARIA to the list items


for (let item of listItems) {
item.setAttribute('role', 'presentation');
}

Next, we’ll use the NodeList.forEach() method to loop through each of the
links.

We need to add a variety of attributes to both thelink elements themselves, as


well as the associated content. We’ll be treating the first element as the active one,
so we also need the index.
/**
* Add ARIA attributes and hide content on page load
*/
function setupDOM () {

// ...

// Add ARIA to the list items


for (let item of listItems) {
item.setAttribute('role', 'presentation');
}

// Add ARIA to the links and content


links.forEach(function (link, index) {
// ...
});

In the callback function, we’ll use the HTMLAnchorElement.hash property to get


the ID selector from the link, and pass it into the document.querySelector()
method.

We’ll assign the returned content element to the tabPane variable. If no tabPane
is found, we’ll use the return operator to skip to the next element in the list.

// Add ARIA to the links and content


links.forEach(function (link, index) {

// Get the the target element


let tabPane = document.querySelector(link.hash);
if (!tabPane) return;

});

Next, we’ll add a [role] attribute to the link, with a value of tab.
We’ll also add the [aria-selected] attribute. If the index is 0, the link is the
first tab, and we’ll use a value of true. Otherwise, we’ll use a value of false.

// Add ARIA to the links and content


links.forEach(function (link, index) {

// Get the the target element


let tabPane = document.querySelector(link.hash);
if (!tabPane) return;

// Add [role] and [aria-selected] attributes


link.setAttribute('role', 'tab');
link.setAttribute('aria-selected', index === 0 ? true :
false);

});

We want to prevent inactive tab triggers from being focused on with the Tab key.

If the index is greater than 0, we’ll also add a [tabindex] of -1. This will allow
us to shift focus to those elements in response to other keyboard interactions, but
remove them from the normal focus order.
// Add ARIA to the links and content
links.forEach(function (link, index) {

// ...

// Add [role] and [aria-selected] attributes


link.setAttribute('role', 'tab');
link.setAttribute('aria-selected', index === 0 ? true :
false);

// If it's not the active (first) tab, remove focus


if (index > 0) {
link.setAttribute('tabindex', -1);
}

});

Next, we’ll add a few attributes to thetabPane content. We’ll add a [role] of
tabpanel, and an [aria-labelledby] attribute with the link.id as its value.

Since the link might not have an ID, we’ll check if it does, and if not, we’ll create
one by prefixing the tabPane.id with tab_.
// Add ARIA to the links and content
links.forEach(function (link, index) {

// ...

// If it's not the active (first) tab, remove focus


if (index > 0) {
link.setAttribute('tabindex', -1);
}

// If there's no ID, add one


if (!link.id) {
link.id = `tab_${tabPane.id}`;
}

// Add ARIA to tab pane


tabPane.setAttribute('role', 'tabpanel');
tabPane.setAttribute('aria-labelledby', link.id);

// If not the active pane, hide it


if (index !== 0) {
tabPane.setAttribute('hidden', '');
}

});

Finally, if the index is greater than 0, the tabPane is not the active one. We’ll add
a [hidden] attribute to hide it.
// Add ARIA to the links and content
links.forEach(function (link, index) {

// ...

// Add ARIA to tab pane


tabPane.setAttribute('role', 'tabpanel');
tabPane.setAttribute('aria-labelledby', link.id);

// If not the active pane, hide it


if (index > 0) {
tabPane.setAttribute('hidden', '');
}

});

Now we can run the setupDOM() method to initialize our script when the page
loads.

// Initialize the script


setupDOM();

Toggling content visibility when a tab is clicked


Whenever a tab is clicked, the currently visible tab content should be hidden, and
content controlled by the clicked tab should be shown.

Let’s start by adding a click event listener. Since there are multiple tabs, we’ll use
event delegation to listen for all clicks on the document, and pass in the
clickHandler() function as our callback.

// Initialize the script


setupDOM();
document.addEventListener('click', clickHandler);
In the clickHandler() function, we’ll first use the Element.matches()
method to check if the clicked element (the event.target) has the
[role="tab"] attribute.

If not, we’ll use the return operator to end the function. Otherwise, we’ll run the
event.preventDefault() method to prevent the anchor link for updating the
URL or causing the page to jump.

/**
* Show content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on tab links


if (!event.target.matches('[role="tab"]')) return;

// Prevent the link from updating the URL


event.preventDefault();

Next, we’ll check if the event.target has the [aria-selected="true"]


attribute.

If so, it’s the currently active tab, so we don’t need to do anything. We can use the
return operator to end the function.
/**
* Show content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on tab links


if (!event.target.matches('[role="tab"]')) return;

// Prevent the link from updating the URL


event.preventDefault();

// Ignore the currently active tab


if (event.target.matches('[aria-selected="true"]'))
return;

Since we’ll also need to active a tab when it receives focus, let’s create a
toggleTab() function to actually show the active tab.

We’ll pass the event.target into it as an argument.


/**
* Show content on click events
* @param {Event} event The event object
*/
function clickHandler (event) {

// Only run on tab links


if (!event.target.matches('[role="tab"]')) return;

// Prevent the link from updating the URL


event.preventDefault();

// Ignore the currently active tab


if (event.target.matches('[aria-selected="true"]'))
return;

// Toggle tab visibility


toggleTab(event.target);

Showing the active tab


In the toggleTab() function, we’ll pass the clicked tab.hash into the
document.querySelector() method to get the associated tabPane.

If there isn’t one, we’ll use the return operator to end the function.
/**
* Toggle tab visibility
* @param {Node} tab The tab to show
*/
function toggleTab (tab) {

// Get the target tab pane


let tabPane = document.querySelector(tab.hash);
if (!tabPane) return;

We also want to deactivate the currently active tab, and hide it’s content.

We’ll use the Element.closest() method to find the nearest parent element of
the tab that has the [role="tablist"] attribute. Then, we’ll use the
Element.querySelector() method to find the element in it that currently has
the [aria-selected="true"] attribute.

We’ll assign the active tab to the currentTab variable. Then, we’ll use the
currentTab.hash property to get it’s content element and assign it to the
currentPane variable.
/**
* Toggle tab visibility
* @param {Node} tab The tab to show
*/
function toggleTab (tab) {

// Get the target tab pane


let tabPane = document.querySelector(tab.hash);
if (!tabPane) return;

// Get the current tab and content


let currentTab =
tab.closest('[role="tablist"]').querySelector('[aria-
selected="true"]');
let currentPane =
document.querySelector(currentTab.hash);

Now that we have all of our elements, we’re ready to add, remove, and update
attributes.

We’ll set [aria-selected] to true on the clicked tab, and false on the
currentTab.
We’ll remove the [hidden] attribute from the tabPane, and add it to the
currentPane.
We’ll remove the [tabindex] attribute from the tab so that it can be focused
as part of the normal focus order.
We’ll add the [tabindex] attribute to the currentTab with a value of -1 so
that it’s removed from the normal focus order.
/**
* Toggle tab visibility
* @param {Node} tab The tab to show
*/
function toggleTab (tab) {

// ...

// Update the selected tab


tab.setAttribute('aria-selected', true);
currentTab.setAttribute('aria-selected', false);

// Update the visible tabPane


tabPane.removeAttribute('hidden');
currentPane.setAttribute('hidden', '');

// Make sure current tab can be focused and other tabs


cannot
tab.removeAttribute('tabindex');
currentTab.setAttribute('tabindex', -1);

Styling the buttons


Now that we have some basic functionality in place, let’s add some styling. We
want our list of links to look more like tabs, and also visually indicate which tab is
currently active.

We can hook into ARIA roles so that our styling only applies after the JavaScript
runs.

To start, we’ll remove the list-style from the [role="tablist"] element.


We’ll also adjust the margin and padding.
[role="tablist"] {
list-style: none;
margin: 0 0 2em;
padding: 0;
}

Next, lets display the tab list items (li) as inline-block.

We’ll give the [role="tab"] links a border, margin, and padding, adjust the
color, and remove the link. We’ll add a light gray background-color on
:hover or when :active.

[role="tablist"] {
list-style: none;
margin: 0 0 2em;
padding: 0;
}

[role="tablist"] li {
display: inline-block;
}

[role="tab"] {
border: 1px solid #808080;
color: #272727;
margin-right: 0.25em;
padding: 0.5em 1em;
text-decoration: none;
}

[role="tab"]:active,
[role="tab"]:hover {
background-color: #e5e5e5;
}
Finally, if the [role="tab"] link has the [aria-selected="true"] attribute
and is the currently active tab, we want to style it differently.

We’ll adjust the background-color and border-color to blue, with a text


color of white.

[role="tab"][aria-selected="true"] {
background-color: #0088cc;
border-color: #0088cc;
color: #ffffff;
}

Navigating tabs with arrow keys


Users should also be able to navigate our tab component using the ArrowLeft and
ArrowRight keys.

Let’s add a keydown event listener, and pass in a keyHandler() function as our
callback.

// Initialize the script


setupDOM();
document.addEventListener('click', clickHandler);
document.addEventListener('keydown', keyHandler);

Inside the keyHandler() function, we can use the event.code property to


detect which key was pressed.

We’ll create an array with ArrowLeft and ArrowRight as values, and use the
Array.includes() method to check if the event.code includes one of those
two values.

If not, we’ll use the return operator to end the callback function.
/**
* Update tab content on keyboard events
* @param {Event} event The event object
*/
function keyHandler (event) {

// Only run for left and right arrow keys


if (!['ArrowLeft', 'ArrowRight'].includes(event.code))
return;

We only want to navigate between tabs with the arrow keys if a tab trigger
currently has focus.

We can use the document.activeElement property to get the element on the


page that currently has focus. We’ll use the Element.closest() method to
check if it has the [role="tab"] attribute, and assign it to the tab variable.

If a tab element isn’t the item that has focus, we’ll use thereturn operator to end
the callback function.
/**
* Update tab content on keyboard events
* @param {Event} event The event object
*/
function keyHandler (event) {

// Only run for left and right arrow keys


if (!['ArrowLeft', 'ArrowRight'].includes(event.code))
return;

// Only run if element in focus is on a tab


let tab =
document.activeElement.closest('[role="tab"]');
if (!tab) return;

Next, we’ll use the Element.closest() method to get the parent


[role="tablist"] for the tab element. Then, we’ll search for the
[role="tab"] element with the [aria-selected="true"] attribute, and
assign it to the currentTab variable.

We’ll get the currentTab element’s parent li, and assign that to the listItem
variable.
/**
* Update tab content on keyboard events
* @param {Event} event The event object
*/
function keyHandler (event) {

// ...

// Only run if element in focus is on a tab


let tab =
document.activeElement.closest('[role="tab"]');
if (!tab) return;

// Get the currently active tab


let currentTab =
tab.closest('[role="tablist"]').querySelector('[role="tab"]
[aria-selected="true"]');

// Get the parent list item


let listItem = currentTab.closest('li');

If the event.code was ArrowRight, we’ll use the nextElementSibling


property to get the next list item element after the active listItem. Otherwise,
we’ll use the previousElementSibling property to get the list item before it.

Either way, we’ll assign the result to thenextListItem variable. If no


nextListItem was found, we’ll end the callback function.

Otherwise, we’ll use the Element.querySelector() method to get the tab link
(a) inside the nextListItem, and assign it to the nextTab variable.
/**
* Update tab content on keyboard events
* @param {Event} event The event object
*/
function keyHandler (event) {

// ...

// Get the parent list item


let listItem = currentTab.closest('li');

// If right arrow, get the next sibling


// Otherwise, get the previous
let nextListItem = event.code === 'ArrowRight' ?
listItem.nextElementSibling :
listItem.previousElementSibling;
if (!nextListItem) return;
let nextTab = nextListItem.querySelector('a');

Finally, we’ll pass the nextTab into our toggleTab() function to activate it.
Then, we’ll use the Element.focus() method to shift focus to the nextTab link.
/**
* Update tab content on keyboard events
* @param {Event} event The event object
*/
function keyHandler (event) {

// ...

// If right arrow, get the next sibling


// Otherwise, get the previous
let nextListItem = event.code === 'ArrowRight' ?
listItem.nextElementSibling :
listItem.previousElementSibling;
if (!nextListItem) return;
let nextTab = nextListItem.querySelector('a');

// Toggle tab visibility


toggleTab(nextTab);
nextTab.focus();

Now, we have a tab component progressively enhanced from a list of anchor links.
Notification
A notification component is an element that displays important information in the
UI without interrupting the user’s current task. It may remain on screen
permanently, disappear on its own after a period of time, or be manually closed by
the user.

Accessibility Requirements
A notification component is referred to by a variety of names: notification, alert,
status, toast.

Only one of those, alert, has an ARIA spec, and the only requirement is that it
have a [role] attribute of alert.

The Template
For this component, we have an empty div with an ID of #notification where
we’ll be displaying our notification messages.

<div id="notification"></div>

Creating a utility function


Notifications typically appear in the UI dynamically in response to events or user
interactions.

For this component, let’s create a utility function that we can run to add a
notification to the UI when needed. We’ll accept the selector for the element to
show our notification in, and a message to display in the UI.
function notify (selector, message) {
// ...
}

In the notify() function, we’ll first pass the selector into the
document.querySelector() method, and assign the matching element to the
target variable.

If no target is found, we’ll use the return operator to end the function.

function notify (selector, message) {

// Get the target element


let target = document.querySelector(selector);
if (!target || !message) return;

Next, we’ll use the document.createElement() method to create a div, and


assign it to the notification variable.

We’ll use the Element.className property to give it a class of .notification,


and the Element.setAttribute() method to give it a [role] attribute of
alert.
function notify (selector, message) {

// Get the target element


let target = document.querySelector(selector);
if (!target || !message) return;

// Create the notification element


let notification = document.createElement('div');
notification.className = 'notification';
notification.setAttribute('role', 'alert');

We’ll use the Element.append() method to inject the notification into the
target element.

Then, we’ll use the Element.innerHTML property to add the message as its
content.

function notify (selector, message) {

// ...

// Create the notification element


let notification = document.createElement('div');
notification.className = 'notification';
notification.setAttribute('role', 'alert');

// Append into the DOM


target.append(notification);

// Add message
notification.innerHTML += message;

Now, we can inject a message into the UI by running thenotify() method with
our selector and message.

// Show a message in the UI


notify('#notification', 'Hello, world!');

// Show another one


// They'll stack
notify('#notification', 'How are you today?');

Styling the notifications


Right now, the notifications just look like ordinary text. We can hook into the
.notification class to add some styling.

Let’s add a light-gray background-color and a slightly darker gray border.


We’ll also add some padding, and a bit of margin at the bottom in case we display
more than one notification at a time.

.notification {
background-color: #f7f7f7;
border: 1px solid #e5e5e5;
margin: 0 0 0.5em;
padding: 0.5em 1em;
}

Announcing the notification


Despite using [role="alert"], if you test the notification with a screen reader,
you’ll discover that it does not get announced. Why not?

ARIA live attributes announce changes to the content in an element. When our
notification is appended to the UI, we add the message text at the same time.
As far as screen readers are concerned, this is not achange to the content, but its
initial state.

To work around this, we can use the window.setTimeout() method to add a 1


millisecond delay before adding the content. It will appear instantaneous to the
user, but will look like a change to screen readers, and trigger an announcement.

(Thanks to Heydon Pickering and his Inclusive Components guide for teaching me this
trick.)

function notify (selector, message) {

// Get the target element


let target = document.querySelector(selector);
if (!target || !message) return;

// Create the notification element


let notification = document.createElement('div');
notification.className = 'notification';
notification.setAttribute('role', 'alert');

// Append into the DOM


target.append(notification);

// Add message
setTimeout(function () {
notification.innerHTML += message;
}, 1);

Now, our notifications will be announced by screen readers.

Automatically removing notifications after a few


moments
While some notifications remain in the UI indefinitely, others automatically hide
themselves after a few moments.

Let’s add another parameter, autohide, to the notify() function. If true, we’ll
automatically remove the notification from the UI after some time.

function notify (selector, message, autohide) {


// ...
}

After adding the message to the notification element, we’ll check if


autohide is true.

If it is, we’ll use the window.setTimeout() method to run the


Element.remove() method on the notification element after 5000
milliseconds, or five seconds.

function notify (selector, message, autohide, dismiss) {

// ...

// Add message
setTimeout(function () {
notification.innerHTML += message;
}, 1);

// If autohide, remove after 5 seconds


if (autohide) {
setTimeout(function () {
notification.remove();
}, 5000);
}

Now, we can create an autohiding notification like this.


// This message will autohide after 5 seconds
notify('#notification', 'Hello, world!', true);

// This one will remain indefinitely


notify('#notification', 'How are you today?');

Dismissing a notification
Notifications can often also be dismissed by a user by clicking a button inside the
notification itself.

Let’s add one more parameter to our notify() function: dismiss. If true, we’ll
add a way for users to dismiss the notification.

function notify (selector, message, autohide, dismiss) {


// ...
}

After creating the notification, but before injecting into the UI, let’s check if
the dismiss parameter is true.

If it is, we’ll use the Element.innerHTML property to add a button element


inside the notification.

We’ll use the HTML hex code for a multiplication sign, (×) as the text. We’ll also
give it a class of .notification-close, and an [aria-label] attribute with
a value of Close.
function notify (selector, message, autohide, dismiss) {

// ...

// Create the notification element


let notification = document.createElement('div');
notification.className = 'notification';
notification.setAttribute('role', 'alert');

// Show dismiss icon


if (dismiss) {
notification.innerHTML = '<button
class="notification-close" aria-
label="Close">&#x2715</button>';
}

// Append into the DOM


target.append(notification);

// ...

Next, we’ll add a click event listener to the notification element, and pass in
a named close() function as the callback method.

When the notification is clicked, we’ll use theElement.matches() method to


check if the event.target is the .notification-close button. If not, we’ll
use the return operator to end the callback function.

If it is, we’ll use the Element.remove() method to remove the notification


element from the UI. Then, we’ll use the Element.removeEventListener()
method to clean up the event listener, since it’s not needed anymore.
function notify (selector, message, autohide, dismiss) {

// ...

// Show dismiss icon


if (dismiss) {
notification.innerHTML = '<button
class="notification-close" aria-
label="Close">&#x2715</button>';
notification.addEventListener('click', function
close (event) {
if (!event.target.matches('.notification-
close')) return;
notification.remove();
notification.removeEventListener('click',
close);
});
}

// ...

We can also add some styling to the .notification-close button.

Let’s remove the background and border, and inherit the color and font
from the parent element. We’ll also float it to the right, and add some margin
on the left and bottom.

.notification-close {
background: transparent;
border: none;
color: inherit;
font: inherit;
float: right;
margin: 0 0 0.5em 0.5em;
}
Now, if we pass a fourth argument into the notify() function with a value of
true, users can dismiss the notification.

// This message can be dismissed by the user, and will


autohide after 5 seconds if they don't
notify('#notification', 'Hello, world!', true, true);

// This message will remain in the UI until dismissed


notify('#notification', 'How are you today?', false, true);
About the Author

Hi, I’m Chris Ferdinandi. I believe there’s a simpler, more resilient way to make
things for the web.

I’m the author of the Vanilla JS Pocket Guide series, creator of the Vanilla JS
Academy training program, and host of the Vanilla JS Podcast. My developer tips
newsletter is read by thousands of developers each weekday.

I love pirates, puppies, and Pixar movies, and live near horse farms in rural
Massachusetts.

You can find me:

On my website at GoMakeThings.com.
By email at [email protected].
On Twitter at @ChrisFerdinandi.

You might also like