Keyboard Users and Accessible Controls

Build controls that work for people who use a keyboard or a screen reader, with real buttons, aria-pressed and aria-expanded that match what the page shows, and an Escape key that closes what's open and puts focus back.

Journeyman · JavaScript · Web Development · Updated

After this lesson you can

  • Choose a real button for a control, so it works with the mouse, the Tab key, Enter and the space bar
  • Keep a toggle button's aria-pressed or aria-expanded in step with what the page shows
  • Close an open panel with the Escape key and move focus back to the button that opened it

Key takeaways

  • Many people use a keyboard instead of a mouse. Tab moves to the next control, Shift+Tab goes back, and Enter or the space bar presses a button.
  • A real button element works with the mouse, Tab, Enter and the space bar, and a click listener hears all of them. A clickable div works only with a mouse.
  • aria-pressed says whether an on/off button is on. aria-expanded says whether the thing a button opens is open. Change the attribute every time the look changes.
  • Escape should close what's open. Then element.focus() puts the keyboard back on the button that opened it, so nobody loses their place.

Not everyone uses a mouse. Someone with a broken wrist, someone with a tremor, someone who is blind and has a screen reader read the page aloud, someone who is faster on the keyboard than with a mouse: all of them move through a page with keys. A heart that only a mouse can press is a heart they can't press. Making every control work from the keyboard is part of building a page that works, and it's often a requirement too: on a class project's rubric, in a job's code review, and in the accessibility laws many countries have for websites.

The good news is that most of it comes from choosing the right element. This lesson shows what keyboard users expect, and the few lines of JavaScript that keep a page honest with them.

Using a page without a mouse

Try this on any website: put the mouse aside and press Tab. A ring, the focus outline, jumps to the first link or button. Each Tab moves it to the next control, and Shift+Tab moves it back. The control with the ring has keyboard focus: it's the one your key presses go to. Enter follows a link or presses a button, and the space bar presses a button too. On a Mac, Safari and Firefox skip buttons and links when you press Tab, until you switch it on: in Safari, Settings > Advanced > Press Tab to highlight each item on a webpage (or press Option+Tab); in Firefox, turn on Keyboard navigation in macOS System Settings > Keyboard. Chrome and Edge reach them already.

The order Tab follows is the order of the controls in the HTML. In this filter bar, Tab goes All, Breakfast, Lunch, Dinner:

<div class="filter-bar">
  <button>All</button>
  <button>Breakfast</button>
  <button>Lunch</button>
  <button>Dinner</button>
</div>

People who use a screen reader, a program that reads the page aloud, move the same way, and hear each control's name and what kind of control it is: "Breakfast, button". In your textbook this is called web accessibility (often written a11y: an a, 11 letters, a y). The guidelines most sites are measured against are WCAG, the Web Content Accessibility Guidelines, and "everything works from the keyboard" is one of them.

A real button does the work for you

These two look the same once they're styled, and both answer a mouse click:

<div class="favorite">♡ Favorite</div>
<button class="favorite">♡ Favorite</button>

Only one of them works for everyone:

What you try <div> <button>
Mouse click Works Works
Tab reaches it No: Tab skips it Yes
Enter and space bar Nothing happens They press it, and your click listener runs
A screen reader says "Favorite" "Favorite, button"

A <button> answers Enter and the space bar with a click event, so the click listener you already wrote works for keyboard users, with no extra code. Making a <div> behave the same takes a tabindex attribute, a role attribute and a keydown listener for two keys, and missing one of the three is a common mistake.

The rule: if it does something on this page, use a <button>. If it goes to another page, use a link, <a href="...">. One more thing to know: a <button> inside a form sends the form when pressed, unless you give it type="button".

aria-pressed: buttons that are on or off

A light switch shows you whether it's on. A heart that turns red does the same for someone who can see it, but a screen reader can't see red. It needs to be told.

ARIA attributes (from Accessible Rich Internet Applications) tell screen readers what a control means. aria-pressed marks a button that stays on or off, a toggle button: "true" when it's on, "false" when it's off. The screen reader then says "Favorite, toggle button, pressed".

<button class="favorite" aria-pressed="false">♡ Favorite</button>

Your JavaScript changes the attribute every time it changes the look. getAttribute('aria-pressed') reads the attribute's value, which is always a string, and setAttribute('aria-pressed', 'true') sets it:

const heart = document.querySelector('.favorite');

heart.addEventListener('click', () => {
  const isOn = heart.getAttribute('aria-pressed') === 'true';
  heart.setAttribute('aria-pressed', String(!isOn));
  heart.textContent = isOn ? '♡ Favorite' : '♥ Favorite';
});

The first click turns it on (aria-pressed="true", a filled heart), and the second turns it off again. The word stays Favorite either way: the attribute says whether it's on, so the name doesn't need to. The filter buttons work the same: the one that's chosen has aria-pressed="true", and the others "false".

A stylesheet can style a button by its attribute, with a selector such as .favorite[aria-pressed="true"]. When the CSS does that, the look and the meaning can never disagree, because there's only one thing to change.

aria-expanded: buttons that open something

The Show steps button doesn't turn itself on; it opens something else, the list of steps. That's what aria-expanded is for: "true" while the thing it controls is open. The steps start hidden, with the hidden attribute:

<button class="show-steps" aria-expanded="false">Show steps</button>
<ol class="steps" hidden>
  <li>Slice the peppers and onions.</li>
  <li>Roast for 20 minutes.</li>
</ol>

hidden hides an element from everyone, screen readers included, and JavaScript sets it with element.hidden = true or false. Each click flips both, together:

const showSteps = document.querySelector('.show-steps');
const steps = document.querySelector('.steps');

showSteps.addEventListener('click', () => {
  const isOpen = showSteps.getAttribute('aria-expanded') === 'true';
  showSteps.setAttribute('aria-expanded', String(!isOpen));
  steps.hidden = isOpen;
});

Which one when? aria-pressed when the button itself is on or off: a heart, a filter, a dark-mode switch. aria-expanded when the button opens and closes something else: a recipe's steps, a menu, a "more details" panel.

Escape closes it, and focus goes back

Keyboard users expect Escape to close whatever just opened: a recipe's steps, a menu, a photo shown full-screen. And when something closes, focus matters. If the element that has focus is hidden, focus falls back to the start of the page, and a keyboard user has to Tab all the way back to where they were. So after closing, put focus back on the button that opened it, with focus():

document.addEventListener('keydown', (event) => {
  if (event.key !== 'Escape' || showSteps.getAttribute('aria-expanded') !== 'true') {
    return;
  }
  showSteps.setAttribute('aria-expanded', 'false');
  steps.hidden = true;
  showSteps.focus();
});

!== is read "not strictly equal". The first if ends the listener early unless the key is Escape and the steps are open. Then it closes them the same way a click does, and showSteps.focus() moves the focus ring back to the button. In the Console, document.activeElement tells you which element has focus right now.

Try it. Add the Show steps button, the steps and both listeners to your weeknight page. Put the mouse aside: Tab to Show steps and press the space bar to open the steps. Press Shift+Tab once, so the focus ring moves back to the control before it, then press Escape. The steps close, and the focus ring jumps back to Show steps. Comment out the showSteps.focus() line and try again: this time the focus ring stays where it was.

Leave the keys alone while someone types

A keydown listener on the whole page hears every key, including the ones someone types into a search box. If cook mode's arrow keys moved the recipe steps while a visitor was moving the cursor in the search box, that would be a bug. Skip key presses that start inside a text box:

document.addEventListener('keydown', (event) => {
  if (event.target.closest('input, textarea')) {
    return;
  }
  console.log('Cook mode heard', event.key);
});

event.target is the element with focus, and closest('input, textarea') finds it when it's a text box (the comma in a selector means "or"). The arrow keys now move the cursor in the search box, as they should, and move through the steps everywhere else.

Check yourself

2 questions to try on the lesson page.

So what

A real <button>, ARIA attributes that tell the truth, and an Escape key that hands focus back: with those, the same page works for a mouse, a keyboard and a screen reader, and you'll write all three habits into every interactive page from here on.

Key terms

Cheat sheets

Related