Project 05 · Beginner
Tabs
A recipe card with three tabs: Ingredients, Method, and Tips. You'll build it three ways: with radio buttons and no JavaScript, with buttons that swap a class, and finally as real tabs that keyboard and screen reader users can rely on.
- 6 HTML ideas
- 6 CSS ideas
- 8 JavaScript ideas
- 3 ways to build it
Why build it
With data attributes, ARIA roles, and arrow-key handling, you can build product pages that switch between Description, Reviews, and Shipping, code samples with a tab per language, and settings screens split into sections. The keyboard pattern you learn here, one stop in the Tab order and arrow keys to move inside, also works for toolbars, menus, and radio groups.
Skills you'll earn
- Link each button to the content it shows with data attributes, and switch it with classList
- Tell screen readers what a custom widget is with ARIA roles
- Respond to the arrow keys, Home, and End with a keydown listener
- Give a group of controls one stop in the Tab order, with a roving tabindex
01
Try it
Play with it first, then read the code that makes it work.
Hidden radio buttons and CSS's :checked switch the panels, and the arrow keys work for free. But screen readers hear radio buttons, not tabs.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tabs</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="recipe">
<h1 class="recipe-title">Pancakes</h1>
<div class="tabs">
<!-- Radios that share a name work as one choice: only one is picked. -->
<input
class="tab-radio"
type="radio"
name="recipe-tab"
id="tab-ingredients"
checked
/>
<!-- for="..." connects each label to its radio, so clicking it picks it. -->
<label class="tab" for="tab-ingredients">Ingredients</label>
<input class="tab-radio" type="radio" name="recipe-tab" id="tab-method" />
<label class="tab" for="tab-method">Method</label>
<input class="tab-radio" type="radio" name="recipe-tab" id="tab-tips" />
<label class="tab" for="tab-tips">Tips</label>
<!-- The panels come after the radios, so CSS can show the picked one. -->
<div class="tab-panel panel-ingredients">
<ul>
<li>1 cup of flour</li>
<li>1 cup of milk</li>
<li>1 egg</li>
<li>2 tablespoons of melted butter</li>
</ul>
</div>
<div class="tab-panel panel-method">
<ol>
<li>Whisk everything into a smooth batter.</li>
<li>Pour a little into a hot, buttered pan.</li>
<li>Flip when bubbles appear, then cook the other side.</li>
</ol>
</div>
<div class="tab-panel panel-tips">
<p>
Let the batter rest for ten minutes, and keep the first pancake for
yourself: it's always the odd one.
</p>
</div>
</div>
</main>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
Hidden radio buttons and CSS's :checked switch the panels, and the arrow keys work for free. But screen readers hear radio buttons, not tabs.
HTMLthe structure
- 01
Use radios that share a
nameRadio buttons let someone pick one option from a set. Radios with the same
name, hererecipe-tab, count as one choice: picking one unpicks the others. That's how tabs behave too, so the browser keeps track of the selected tab for you.checkedon the first radio makes Ingredients the tab that's open to start with.<!-- Radios that share a name work as one choice: only one is picked. --> <input class="tab-radio" type="radio" name="recipe-tab" id="tab-ingredients" checked /> - 02
Connect each label with
forA
<label>is text that names a form control. Itsforattribute matches a radio'sid, which connects the two, so clicking the label picks that radio. The radios themselves are hidden, so the labels are what you see and click, styled to look like tabs.<!-- for="..." connects each label to its radio, so clicking it picks it. --> <label class="tab" for="tab-ingredients">Ingredients</label> <input class="tab-radio" type="radio" name="recipe-tab" id="tab-method" /> <label class="tab" for="tab-method">Method</label> <input class="tab-radio" type="radio" name="recipe-tab" id="tab-tips" /> <label class="tab" for="tab-tips">Tips</label>
CSSthe look
- 03
Hide the radios but keep them working
opacity: 0makes the radios see-through without removing them, andposition: absolutetakes them out of the layout so they don't leave a gap. They're still on the page, so Tab reaches them and the arrow keys move between them. The:focus-visiblerule draws the focus outline on the label after the focused radio, so keyboard users can see where they are./* The radios stay in the page for keyboards, but out of sight. */ .tab-radio { position: absolute; opacity: 0; } .tab { padding: 0.625rem 0.75rem; border-radius: 999px; background: #f4f4f6; font-weight: 600; text-align: center; color: #6b6b76; cursor: pointer; } .tab:hover { color: #1c1c22; } /* + picks the label straight after the checked radio: the selected tab. */ .tab-radio:checked + .tab { background: #1c1c22; color: #ffffff; } .tab-radio:focus-visible + .tab { outline: 3px solid #2563eb; outline-offset: 3px; } - 04
Style the picked tab with
:checked +:checkedmatches a radio that's picked.+selects the element straight after another, when both share the same parent. So.tab-radio:checked + .tabfinds the label right after the picked radio and styles it as the selected tab. This only works because each label comes directly after its radio in the HTML./* + picks the label straight after the checked radio: the selected tab. */ .tab-radio:checked + .tab { background: #1c1c22; color: #ffffff; } - 05
Show the matching panel with
:checked ~~selects any later sibling, not only the next one. Every panel starts withdisplay: none, and rules like#tab-method:checked ~ .panel-methodshow the panel that matches the picked radio. These selectors can only reach elements that come later, never earlier, which is why the panels have to come after the radios in the HTML./* ~ reaches any later sibling, so each checked radio shows its own panel. */ #tab-ingredients:checked ~ .panel-ingredients, #tab-method:checked ~ .panel-method, #tab-tips:checked ~ .panel-tips { display: block; } - 06
Stop the card jumping with
min-heightEach panel holds a different amount of text, so each one would be a different height. Without a fixed size, the card would grow and shrink as you switch, and the tabs could move under your cursor.
min-height: 9remmakes every panel at least that tall, so the card stays the same height./* A fixed minimum height stops the card jumping when panels change size. */ .tab-panel { display: none; grid-column: 1 / -1; min-height: 9rem; margin-top: 1.25rem; line-height: 1.6; color: #4b4b55; }
HTMLthe structure
- 01
Link a button to its panel with
data-attributesYou can add your own attributes to any element, as long as the name starts with
data-. The browser ignores them, so they're a safe place to store information for your JavaScript. Each button'sdata-tabholds theidof the panel it shows, likemethod, so the script knows which panel to open.<div class="tab-list"> <!-- data-tab holds the id of the panel each button shows. --> <button type="button" class="tab is-active" data-tab="ingredients"> Ingredients </button> <button type="button" class="tab" data-tab="method">Method</button> <button type="button" class="tab" data-tab="tips">Tips</button> </div> <!-- Each panel's id matches a button's data-tab. --> <div class="tab-panel" id="ingredients">
CSSthe look
- 02
Style the selected tab with a class
.is-activeholds the selected look: a dark background and white text. The first tab has the class in the HTML, so it starts selected. JavaScript never changes any colours itself; it only moves the class from tab to tab, and CSS decides what that looks like./* JavaScript moves the is-active class to whichever tab was clicked. */ .tab.is-active { background: #1c1c22; color: #ffffff; }
JavaScriptthe behaviour
- 03
Read
data-attributes withdatasetdatasetis how JavaScript reads an element'sdata-attributes. Each one appears without thedata-part, sotab.dataset.tabreads the button'sdata-tab, likemethod.document.getElementById()then finds the panel with thatid, and setting itshiddentofalseshows it.// dataset.tab reads the button's data-tab attribute. const panel = document.getElementById(tab.dataset.tab); panel.hidden = false; - 04
Swap classes with
classListclassListis the list of classes on an element, with methods to change them one at a time.classList.remove("is-active")takes the class off every tab, andclassList.add("is-active")puts it on the clicked one. Other classes on the button, liketab, stay as they are.// Unselect every tab and hide every panel first... tabs.forEach((otherTab) => otherTab.classList.remove("is-active")); panels.forEach((panel) => { panel.hidden = true; }); // ...then select this tab and show its panel. tab.classList.add("is-active"); - 05
Reset everything, then select one
querySelectorAll()finds every matching element, andforEachruns a function once for each one, here adding a click listener to every tab. When a tab is clicked, the code first hides every panel and unselects every tab, then selects only the clicked one. That way you never need to remember which tab was open before.tabs.forEach((tab) => { tab.addEventListener("click", () => { // Unselect every tab and hide every panel first... tabs.forEach((otherTab) => otherTab.classList.remove("is-active")); panels.forEach((panel) => { panel.hidden = true; }); // ...then select this tab and show its panel. tab.classList.add("is-active"); // dataset.tab reads the button's data-tab attribute. const panel = document.getElementById(tab.dataset.tab); panel.hidden = false; }); });
HTMLthe structure
- 01
Name the parts with
roleThere's no
<tabs>element in HTML, so these are ordinary buttons and<div>s.roletells screen readers what each part is:role="tablist"for the group,role="tab"for each button, androle="tabpanel"for each panel.aria-selectedmarks the open tab, andaria-controlspoints each tab at its panel'sid.<!-- role="tablist" tells screen readers these buttons are a set of tabs. --> <div class="tab-list" role="tablist" aria-label="Pancake recipe"> <!-- Only the selected tab is in the Tab order; arrow keys reach the rest. --> <button type="button" class="tab" role="tab" id="tab-ingredients" aria-selected="true" aria-controls="panel-ingredients" > Ingredients </button> - 02
Keep only one tab in the Tab order
tabindexcontrols whether the Tab key stops on an element. Screen reader users expect Tab to move past the whole tab list in one step, not stop on every tab. Sotabindex="-1"takes Method and Tips out of the Tab order, and the arrow keys reach them instead. The script moves this as the selection changes, which is called a roving tabindex.<button type="button" class="tab" role="tab" id="tab-method" aria-selected="false" aria-controls="panel-method" tabindex="-1" > Method </button> - 03
Connect each panel to its tab
aria-labelledbypoints at another element'sidand uses its text as a name. Each panel points at its tab, so screen readers announce "Method" when you reach that panel.tabindex="0"adds the panel to the Tab order, so pressing Tab from the selected tab moves keyboard users into the panel's text.<!-- Each panel is named by its tab; tabindex="0" lets keyboards reach it. --> <div class="tab-panel" role="tabpanel" id="panel-ingredients" aria-labelledby="tab-ingredients" tabindex="0" >
CSSthe look
- 04
Style the selected tab from
aria-selectedCSS can select elements by their attributes, not only by classes.
.tab[aria-selected="true"]matches the open tab, so the attribute that tells screen readers which tab is open also decides how it looks. The two can never disagree, and the script doesn't need a separate class to keep in step./* The selected tab is styled from the attribute screen readers read. */ .tab[aria-selected="true"] { background: #1c1c22; color: #ffffff; }
JavaScriptthe behaviour
- 05
Put "switch tab" in one function
selectTab()holds everything that happens when a tab is picked, so clicks and key presses both call it. It loops over every tab and works outisSelected. The chosen tab getsaria-selected="true", atabIndexof 0, and its panel shown; every other tab gets the opposite. Thenfocus()moves the keyboard to the new tab.// Select one tab: show its panel, hide the others, and move focus to it. function selectTab(newTab) { tabs.forEach((tab) => { const isSelected = tab === newTab; tab.setAttribute("aria-selected", isSelected); // Only the selected tab can be reached with the Tab key. tab.tabIndex = isSelected ? 0 : -1; const panel = document.getElementById(tab.getAttribute("aria-controls")); panel.hidden = !isSelected; }); newTab.focus(); } - 06
Choose between two values with
? :? :is called the ternary operator.isSelected ? 0 : -1reads as "ifisSelectedis true, use 0, otherwise use -1". It's a shortiffor when you only need to pick between two values, here the tab'stabIndex.// Only the selected tab can be reached with the Tab key. tab.tabIndex = isSelected ? 0 : -1; - 07
Listen for keys with
keydownA
keydownevent fires each time a key is pressed while the element has focus, andevent.keynames the key, like"ArrowRight"or"Home". Each tab gets its own listener, and theifandelse ifchecks work out which tab comes next.nextIndexis declared withletand no value, because the key decides what it becomes.// event.key says which key was pressed, like "ArrowRight" or "Home". tab.addEventListener("keydown", (event) => { const lastIndex = tabs.length - 1; let nextIndex; if (event.key === "ArrowRight") { // Past the last tab, wrap round to the first. nextIndex = index === lastIndex ? 0 : index + 1; } else if (event.key === "ArrowLeft") { nextIndex = index === 0 ? lastIndex : index - 1; } else if (event.key === "Home") { nextIndex = 0; } else if (event.key === "End") { nextIndex = lastIndex; } else { // Any other key keeps doing what it normally does. return; } - 08
Wrap from the last tab back to the first
forEachalso passes each tab's position in the list,index, counting from 0. ArrowRight adds 1, but on the last tab it goes back to 0 instead, and ArrowLeft on the first tab goes tolastIndex. Without those checks, the position would run past either end of the list and there'd be no tab to select.// forEach also passes each tab's position in the list: 0, 1, 2. tabs.forEach((tab, index) => { tab.addEventListener("click", () => selectTab(tab)); // event.key says which key was pressed, like "ArrowRight" or "Home". tab.addEventListener("keydown", (event) => { const lastIndex = tabs.length - 1; let nextIndex; if (event.key === "ArrowRight") { // Past the last tab, wrap round to the first. nextIndex = index === lastIndex ? 0 : index + 1; } else if (event.key === "ArrowLeft") { nextIndex = index === 0 ? lastIndex : index - 1; - 09
Stop the page scrolling with
preventDefault()Many keys have a built-in action, called the default: the arrow keys, Home, and End scroll the page.
event.preventDefault()cancels that action. The code only reaches it for the four keys it handles; any other key hitsreturnfirst and leaves the function early, so Tab, Enter, and the rest work as normal.} else { // Any other key keeps doing what it normally does. return; } // Stop the arrow keys and Home and End from scrolling the page. event.preventDefault(); selectTab(tabs[nextIndex]);
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Update the page's address when a tab is picked, so a link can open a specific tab.
- Challenge 02
Turn the tabs into an accordion on narrow screens.
- Challenge 03
Remember the last tab picked with localStorage.
Finished Tabs?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)