Skip to content
milan.swiss Halbton v0.1.0
Concept project

Components

Tabs

One panel visible at a time, switched without leaving the page. The keyboard contract is the whole component — get it wrong and a ten-tab interface costs a keyboard user ten presses to walk past.

When to use it

For alternative views of one thing, where only one is useful at a time and the user is expected to switch between them freely.

When not to

  • Not for sequential steps. Tabs imply the panels are peers and can be visited in any order. A wizard is not that; it has progress and a next step.
  • Not for content people need to compare. Anything requiring the user to remember panel A while reading panel B should be on screen together.
  • Not for primary navigation. Tabs do not change the URL, so a tabbed site cannot be linked into or reached with the back button.
  • Not with more than about six. A scrolling tab strip hides its own options.

Try it with the keyboard

Tab into the list — it takes one press to reach it and one more to leave. Then use the arrow keys.

Invoice 2026-0114 · CHF 4,200.00 · due 30 days. Sent to accounts on 14 January.

Keyboard

Exercised in a browser, not asserted.
Key Result
Tab Moves to the selected tab, then out to its panel. Never to the other tabs.
→ / ← Moves between tabs and activates as it goes, wrapping at both ends.
Home / End Jumps to the first or last tab. With six tabs, arrowing to the last is five presses; this is one.

Activation follows focus

Arrowing to a tab selects it immediately, rather than requiring Enter. That is the right trade when panels are already in the DOM and switching is free — it matches what sighted mouse users get. If a panel were expensive to load, manual activation would be correct instead, and the component would need a separate mode. Halbton's panels are cheap, so it does not have one.

Markup

markup contract
<div class="ht-tabs" data-ht-tabs>
  <div class="ht-tabs__list" role="tablist" aria-label="Invoice details">
    <button class="ht-tabs__tab" role="tab" id="t-1"
            aria-controls="p-1" aria-selected="true" tabindex="0">Summary</button>
    <button class="ht-tabs__tab" role="tab" id="t-2"
            aria-controls="p-2" aria-selected="false" tabindex="-1">Lines</button>
  </div>

  <div class="ht-tabs__panel" role="tabpanel" id="p-1"
       aria-labelledby="t-1" tabindex="0">…</div>
  <div class="ht-tabs__panel" role="tabpanel" id="p-2"
       aria-labelledby="t-2" tabindex="0" hidden>…</div>
</div>

Accessibility

  • The list is role="tablist" with an aria-label saying what the tabs are for — "Invoice details", not "Tabs".
  • Each panel is labelled by its tab, so its accessible name is the tab's text without repeating it.
  • Panels are tabindex="0", so a keyboard user can move focus into panel content that has no focusable children of its own.
  • The selected tab is marked by weight and an underline as well as colour.
  • Focus is never moved on setup — only in response to a key or a click. Stealing focus on load would yank a reader out of position.

Source

Framework-free, and the demo above runs this exact module. What you are reading is what just handled your arrow keys.

components/halbton/lib/tabs.mjs
/**
 * Tabs, with a roving tabindex.
 *
 * Framework-free, like the modal, and documented by rendering this file's own
 * source. The Tabs page runs exactly this module.
 *
 * The contract that matters, and the one most implementations get wrong: a tab
 * list is **one** stop in the page's tab order, not one per tab. Tab moves into
 * the active tab and then out to the panel; the arrow keys move between tabs.
 * Making every tab focusable with Tab means a ten-tab interface costs a
 * keyboard user ten presses to walk past.
 *
 * Markup contract:
 *
 *   <div class="ht-tabs" data-ht-tabs>
 *     <div class="ht-tabs__list" role="tablist" aria-label="…">
 *       <button role="tab" id="t1" aria-controls="p1" aria-selected="true"  tabindex="0">One</button>
 *       <button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">Two</button>
 *     </div>
 *     <div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
 *     <div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
 *   </div>
 */

/** @param {HTMLElement} root @param {HTMLElement} tab @param {boolean} focus */
function select(root, tab, focus) {
  const tabs = [...root.querySelectorAll('[role="tab"]')];

  for (const t of tabs) {
    const selected = t === tab;
    t.setAttribute("aria-selected", String(selected));
    /* The roving part: only the selected tab is reachable with Tab. */
    t.tabIndex = selected ? 0 : -1;

    const panel = document.getElementById(t.getAttribute("aria-controls"));
    if (panel) panel.hidden = !selected;
  }

  /* Only move focus for keyboard and click activation, never on initial
     setup — stealing focus on page load would yank a reader out of place. */
  if (focus) tab.focus();
}

/** @param {HTMLElement} root */
export function initTabs(root) {
  const tabs = [...root.querySelectorAll('[role="tab"]')];
  if (!tabs.length) return;

  const active = tabs.find((t) => t.getAttribute("aria-selected") === "true") ?? tabs[0];
  select(root, active, false);

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

  root.addEventListener("keydown", (event) => {
    const current = event.target.closest('[role="tab"]');
    if (!current) return;

    const i = tabs.indexOf(current);
    let next = null;

    /* Home and End matter more than they look: with ten tabs, arrowing to the
       last one is nine presses. */
    if (event.key === "ArrowRight") next = tabs[(i + 1) % tabs.length];
    else if (event.key === "ArrowLeft") next = tabs[(i - 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(root, next, true);
  });
}

/** Wire every tab set on the page. */
export function init() {
  document.querySelectorAll("[data-ht-tabs]").forEach(initTabs);
}