Building a Tabbed Options Page Layout

Implement accessible ARIA tabs in an MV3 options page — keyboard navigation, deep-linking via URL hash, plain JS and React versions, and cross-browser testing.

Published June 19, 2026 Updated June 19, 2026 9 min read
Table of Contents

A common failure in extension options pages is building a custom tab switcher with display:none divs and click handlers that look like tabs but behave nothing like them. Screen readers announce each “tab” as an ordinary button, arrow-key navigation does not work, and reloading the page lands the user on the first tab regardless of where they were. This guide fixes that with the ARIA tab pattern, hash-based deep-linking, and keyboard navigation that matches browser native behaviour. It is part of the options page layouts guide.

Why custom tabs break in extensions

MV3 options pages run in a full browser tab (when open_in_tab: true) or an embedded panel. Either way, the page is a standard HTML document with one unusual constraint: no inline scripts. Every behaviour must be wired in external JavaScript loaded as a module. That means the tab-switching logic, the ARIA state updates, and the keyboard handler all live in a .js file — which is fine, but it means you cannot use the common shortcut of onclick="switchTab(this)" in the HTML. Write the JS first, then attach it.

The deeper issue: a <div> with a click handler is not a tab. The role="tablist" / role="tab" / role="tabpanel" ARIA triad is what tells assistive technology how to navigate the component. Without it, VoiceOver reads your UI as a list of buttons inside a generic container, and keyboard users have no way to discover the relationship between a button and the panel it controls.

Step 1 — HTML structure

Write the tab bar and panels as semantic HTML first. The JavaScript layer adds ARIA state; the HTML provides the structure.

 1<!-- options.html -->
 2<!DOCTYPE html>
 3<html lang="en">
 4<head>
 5  <meta charset="utf-8">
 6  <title>Extension Settings</title>
 7  <link rel="stylesheet" href="options.css">
 8</head>
 9<body>
10  <main class="opts-main">
11    <div class="tbl-tabgroup">
12      <div role="tablist" aria-label="Settings" class="tbl-tablist">
13        <button
14          role="tab"
15          id="tab-general"
16          aria-controls="panel-general"
17          aria-selected="true"
18          class="tbl-tab"
19        >General</button>
20        <button
21          role="tab"
22          id="tab-privacy"
23          aria-controls="panel-privacy"
24          aria-selected="false"
25          tabindex="-1"
26          class="tbl-tab"
27        >Privacy</button>
28        <button
29          role="tab"
30          id="tab-appearance"
31          aria-controls="panel-appearance"
32          aria-selected="false"
33          tabindex="-1"
34          class="tbl-tab"
35        >Appearance</button>
36      </div>
37
38      <div role="tabpanel" id="panel-general" aria-labelledby="tab-general" class="tbl-panel">
39        <h2>General</h2>
40        <!-- general settings form fields -->
41      </div>
42      <div role="tabpanel" id="panel-privacy" aria-labelledby="tab-privacy" class="tbl-panel" hidden>
43        <h2>Privacy</h2>
44      </div>
45      <div role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance" class="tbl-panel" hidden>
46        <h2>Appearance</h2>
47      </div>
48    </div>
49  </main>
50  <script src="options.js" type="module"></script>
51</body>
52</html>

Execution context: Browser tab (options page renderer). The type="module" attribute on the <script> tag is required for ES-module imports. It also implicitly defers execution until the DOM is ready, so no DOMContentLoaded wrapper is needed for the script entry point.

Key details:

  • Only the active tab gets aria-selected="true". All other tabs get tabindex="-1" so that Tab key focus moves into the active tab directly, not through all tabs in sequence.
  • Panels use the HTML hidden attribute, not display:none via a class, because some screen readers skip hidden elements rather than reading them as “hidden”.

Step 2 — CSS for the tab bar

 1/* options.css — tab component (tbl- prefix for page uniqueness) */
 2.tbl-tablist {
 3  display: flex;
 4  gap: 0;
 5  border-bottom: 2px solid color-mix(in srgb, currentColor 12%, transparent);
 6  margin-bottom: 1.5rem;
 7}
 8
 9.tbl-tab {
10  background: none;
11  border: none;
12  border-bottom: 2px solid transparent;
13  margin-bottom: -2px; /* overlap tablist border */
14  padding: 0.625rem 1.25rem;
15  font-size: 0.9375rem;
16  font-weight: 500;
17  color: inherit;
18  cursor: pointer;
19  opacity: 0.65;
20  transition: opacity 0.15s, border-color 0.15s;
21}
22
23.tbl-tab[aria-selected="true"] {
24  opacity: 1;
25  border-bottom-color: #2563eb;
26  color: #2563eb;
27}
28
29.tbl-tab:focus-visible {
30  outline: 2px solid #2563eb;
31  outline-offset: 2px;
32  border-radius: 4px;
33}
34
35.tbl-panel {
36  padding: 0.25rem 0;
37}
38
39@media (prefers-color-scheme: dark) {
40  .tbl-tab[aria-selected="true"] {
41    border-bottom-color: #60a5fa;
42    color: #60a5fa;
43  }
44  .tbl-tab:focus-visible {
45    outline-color: #60a5fa;
46  }
47}

Execution context: Static CSS bundle served from the extension package. color-mix() requires Chrome 111+, Firefox 113+, Safari 16.2+ — add a plain rgba() fallback if you target earlier builds. The -2px bottom margin trick avoids a double-border at the active tab without JavaScript.

Step 3 — JavaScript: activation, ARIA, and keyboard navigation

The ARIA tab keyboard contract is specific: Tab moves focus into the active tab (and out of the tablist into the panel). Arrow Left / Arrow Right switch tabs. Home / End jump to first / last tab. Activating a tab with Enter or Space is handled automatically because tabs are <button> elements.

 1// options.js
 2const tablist = document.querySelector('[role="tablist"]');
 3const tabs = [...tablist.querySelectorAll('[role="tab"]')];
 4
 5function activateTab(tab) {
 6  // deactivate all
 7  tabs.forEach(t => {
 8    t.setAttribute('aria-selected', 'false');
 9    t.setAttribute('tabindex', '-1');
10  });
11  // activate target
12  tab.setAttribute('aria-selected', 'true');
13  tab.setAttribute('tabindex', '0');
14  tab.focus();
15
16  // show/hide panels
17  const panelId = tab.getAttribute('aria-controls');
18  document.querySelectorAll('[role="tabpanel"]').forEach(panel => {
19    panel.hidden = panel.id !== panelId;
20  });
21
22  // deep-link: update hash without adding a history entry
23  history.replaceState(null, '', `#${panelId}`);
24}
25
26// click
27tablist.addEventListener('click', e => {
28  const tab = e.target.closest('[role="tab"]');
29  if (tab) activateTab(tab);
30});
31
32// keyboard
33tablist.addEventListener('keydown', e => {
34  const idx = tabs.indexOf(document.activeElement);
35  if (idx === -1) return;
36
37  let next;
38  if (e.key === 'ArrowRight') next = tabs[(idx + 1) % tabs.length];
39  else if (e.key === 'ArrowLeft') next = tabs[(idx - 1 + tabs.length) % tabs.length];
40  else if (e.key === 'Home') next = tabs[0];
41  else if (e.key === 'End') next = tabs[tabs.length - 1];
42
43  if (next) {
44    e.preventDefault();
45    activateTab(next);
46  }
47});
48
49// restore from hash on load
50function restoreFromHash() {
51  const hash = location.hash.replace('#', '');
52  const target = hash ? tabs.find(t => t.getAttribute('aria-controls') === hash) : null;
53  if (target) activateTab(target);
54}
55
56restoreFromHash();
57window.addEventListener('hashchange', restoreFromHash);

Execution context: Options page renderer, loaded as an ES module. history.replaceState updates the URL without a navigation event — the user can bookmark or share the URL and land on the correct tab. hashchange fires when the user navigates browser history back/forward, so restoreFromHash is wired to both events.

Step 4 — Framework version (React)

When your options page is already built with React (bundled, not CDN), the same ARIA contract applies — only the imperative DOM calls become state.

 1// TabGroup.tsx
 2import { useState, useCallback, useRef } from 'react';
 3
 4type Tab = { id: string; label: string };
 5
 6interface TabGroupProps {
 7  tabs: Tab[];
 8  children: React.ReactNode[];
 9}
10
11export function TabGroup({ tabs, children }: TabGroupProps) {
12  const defaultTab = location.hash.replace('#', '') || tabs[0].id;
13  const [activeId, setActiveId] = useState(defaultTab);
14  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
15
16  const activate = useCallback((id: string) => {
17    setActiveId(id);
18    history.replaceState(null, '', `#${id}`);
19  }, []);
20
21  const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
22    let next = -1;
23    if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length;
24    else if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length;
25    else if (e.key === 'Home') next = 0;
26    else if (e.key === 'End') next = tabs.length - 1;
27    if (next !== -1) {
28      e.preventDefault();
29      activate(tabs[next].id);
30      tabRefs.current[next]?.focus();
31    }
32  };
33
34  return (
35    <div>
36      <div role="tablist" aria-label="Settings" className="tbl-tablist">
37        {tabs.map((tab, idx) => (
38          <button
39            key={tab.id}
40            role="tab"
41            id={`tab-${tab.id}`}
42            aria-controls={`panel-${tab.id}`}
43            aria-selected={activeId === tab.id}
44            tabIndex={activeId === tab.id ? 0 : -1}
45            className="tbl-tab"
46            ref={el => { tabRefs.current[idx] = el; }}
47            onClick={() => activate(tab.id)}
48            onKeyDown={e => handleKeyDown(e, idx)}
49          >
50            {tab.label}
51          </button>
52        ))}
53      </div>
54      {tabs.map((tab, idx) => (
55        <div
56          key={tab.id}
57          role="tabpanel"
58          id={`panel-${tab.id}`}
59          aria-labelledby={`tab-${tab.id}`}
60          hidden={activeId !== tab.id}
61          className="tbl-panel"
62        >
63          {children[idx]}
64        </div>
65      ))}
66    </div>
67  );
68}

Execution context: Bundled React component running in the options page renderer. The bundle must be a static file in the extension package — no CDN imports. history.replaceState works identically here; React state drives the ARIA attributes so the DOM always reflects the current tab without direct manipulation.

Cross-browser variation

  • Chrome / Edge: Full support for ARIA tab pattern and history.replaceState in the options tab. tabindex="-1" on inactive tabs behaves as specified.
  • Firefox: The embedded options panel (without open_in_tab: true) is a restricted iframe — history.replaceState may throw a SecurityError. Use open_in_tab: true when deep-linking via hash is required, or skip the replaceState call and drive state from an in-memory variable only.
  • Safari: Safari 15.4+ supports the options tab. ARIA tab pattern works correctly. ArrowLeft / ArrowRight keyboard navigation is tested in VoiceOver + Safari; VoiceOver may announce tabs with its own verbosity regardless of aria-selected state — verify with a real device, not just the simulator.

Verification

  1. Open the options page, click a non-default tab, then reload the page. The same tab must be active after reload (hash preserved via replaceState).
  2. Focus the active tab with Tab key, then press ArrowRight / ArrowLeft — focus must move between tabs without leaving the tablist.
  3. Press Home / End — focus must jump to first / last tab respectively.
  4. Press Tab from within the active tab — focus must move into the active panel, not to the next tab button.
  5. Open Chrome DevTools → Accessibility panel → inspect the tablist. Confirm role="tablist", role="tab" (with aria-selected), and role="tabpanel" are all present and linked by id/aria-controls.
  6. Run a screen reader (VoiceOver on macOS, NVDA on Windows): navigate to the tablist and verify it is announced as “Settings, tab list” with each tab’s selected state read aloud.

FAQ

Why use history.replaceState instead of location.hash = id?

Assigning to location.hash adds an entry to the browser history stack. The user pressing Back would cycle through every tab they visited rather than navigating away from the options page. replaceState updates the URL in place with no history entry.

Can I use <a href="#panel-id"> instead of <button> for tabs?

No. Anchor elements announce as links, not tabs, and their default keyboard behaviour (Enter activates, no arrow navigation) conflicts with the ARIA tab pattern. Use <button> and manage keyboard events manually.

My screen reader reads the panel content before announcing the tab switch. How do I fix it?

Set aria-live="polite" on a visually-hidden status element and update its text when a tab activates (e.g., “Privacy settings panel displayed”). Do not put aria-live on the panel itself — screen readers will announce the entire panel content on every switch.

Does hidden work the same as display:none for accessibility?

For most purposes yes — both remove the element from the accessibility tree. The difference is that display:none can be overridden by a stylesheet (which has accidentally revealed hidden panels in the past). hidden is harder to override accidentally and is the more explicit semantic choice.

Other UI/UX Patterns & Interactive Components Resources