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.
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.
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.
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.
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:
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.hidden attribute, not display:none via a class, because some screen readers skip hidden elements rather than reading them as “hidden”. 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.
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.
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.
history.replaceState in the options tab. tabindex="-1" on inactive tabs behaves as specified.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.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.replaceState).role="tablist", role="tab" (with aria-selected), and role="tabpanel" are all present and linked by id/aria-controls.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.
<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.
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.
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.
chrome.storage.sync with debounced autosave.Implement persistent side panels and DevTools extension panels in MV3 using chrome.sidePanel and chrome.devtools.panels — per-tab control, lifecycle, and cross-browser gaps.
Build chrome.contextMenus items in MV3: register in onInstalled, handle onClicked in the service worker, create parent/child menus, and gate visibility by context type.
Register and handle keyboard shortcuts in MV3 extensions using the chrome.commands API — manifest declaration, service worker listeners, per-OS suggested keys, the 4-shortcut limit, and cross-browser rebinding via chrome://extensions/shortcuts.
Design scalable options page UIs for MV3 extensions — sidebar nav, tabbed sections, responsive grids, form components, autosave UX, and dark mode support.