Skip to content

JavaScript Tutorial Practical Projects

Project 3: Accessible Tabs

Lesson 242 of 258 2 min read By Deepak Paswan Updated Oct 4, 2026

Tabs show one panel at a time. They are a good test of accessible JavaScript, because the keyboard behaviour is specific: arrow keys move between tabs.

index.html
HTML
<style>
  .tabs { font-family: system-ui, sans-serif; max-width: 360px; }
  [role="tablist"] { display: flex; border-bottom: 2px solid #1a5276; }
  [role="tab"] { font: inherit; padding: 8px 14px; border: 0; background: none; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; }
  [role="tab"][aria-selected="true"] { border-bottom-color: #d94a00; font-weight: 700; }
  [role="tab"]:focus-visible { outline: 3px solid #d94a00; outline-offset: -3px; }
  [role="tabpanel"] { padding: 12px 4px; }
</style>

<div class="tabs" id="tabs">
  <div role="tablist" aria-label="Course topics">
    <button role="tab" id="t1" aria-controls="p1" aria-selected="true" tabindex="0">HTML</button>
    <button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">CSS</button>
    <button role="tab" id="t3" aria-controls="p3" aria-selected="false" tabindex="-1">JavaScript</button>
  </div>
  <div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">HTML gives a page its structure.</div>
  <div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>CSS controls how it looks.</div>
  <div role="tabpanel" id="p3" aria-labelledby="t3" tabindex="0" hidden>JavaScript makes it respond.</div>
</div>

<script>
  const tablist = document.querySelector("[role='tablist']");
  const tabs = [...tablist.querySelectorAll("[role='tab']")];

  function select(tab, setFocus = true) {
    for (const t of tabs) {
      const active = t === tab;
      t.setAttribute("aria-selected", String(active));
      t.tabIndex = active ? 0 : -1;
      document.getElementById(t.getAttribute("aria-controls")).hidden = !active;
    }
    if (setFocus) tab.focus();
  }

  tablist.addEventListener("click", event => {
    const tab = event.target.closest("[role='tab']");
    if (tab) select(tab);
  });

  tablist.addEventListener("keydown", event => {
    const index = tabs.indexOf(document.activeElement);
    if (index === -1) return;

    let next;
    if (event.key === "ArrowRight") next = tabs[(index + 1) % tabs.length];
    else if (event.key === "ArrowLeft") next = tabs[(index - 1 + tabs.length) % tabs.length];
    else if (event.key === "Home") next = tabs[0];
    else if (event.key === "End") next = tabs[tabs.length - 1];
    else return;

    event.preventDefault();
    select(next);
  });
</script>

What makes it accessible

  • Roles: tablist, tab, tabpanel. A screen reader announces “HTML, tab, 1 of 3, selected”.
  • aria-selected marks the active tab, aria-controls and aria-labelledby connect tabs and panels.
  • Roving tabindex: only the selected tab is in the Tab order. Arrow keys, Home and End move between tabs (Keyboard Interaction).
  • Inactive panels use hidden, so they are not reachable.
  • The tabs are real <button>s, so Enter and Space work.
  • A visible focus style.

How it is organised

  • select(tab) is the one function that changes state. Clicks and arrow keys both call it.
  • Event delegation: one click listener on the tab list.

Try changing it

  1. Make the selected tab follow the URL hash (#css).
  2. Remember the selected tab in localStorage.
  3. Add a fourth tab without touching the JavaScript: it should just work (it does, because the code reads the tabs from the page).
lightbulb
Native alternatives
If your content is long and independent, headings and sections (or <details>) are often better than tabs. Use tabs when users genuinely switch between related views.