Accessible tabs
Build a tab set with ARIA roles, a roving tabindex, arrow-key navigation with wrap-around, and hidden panels, and test it with keyboard events.
This recipe builds a tab set that follows the WAI-ARIA tabs pattern with automatic activation: focus and selection move
together. It demonstrates useId for the aria-controls and aria-labelledby links, useMemo for the active tab,
keyed For for the tabs and panels.
Choosing For or Index#
A tab is identified by its id, and everything attached to it (focus, aria-selected, the DOM ids that other elements
point to) must follow that identity. For with key matches rows by key(tab), so when the
tabs array is reordered, a tab keeps its DOM node, and a focused tab stays focused.
Index matches by position: after a reorder, the node at position 0 would show a
different tab's label while keeping the first tab's focus and attributes. Index suits lists of primitives where
position is the identity. Tabs are not that, so the component uses keyed For, which passes an accessor, tab(), to
the row callback.
The component#
The selected tab is stored as an id. active is a memo that falls back to the first tab when nothing is selected or
the selected tab has been removed, so the component never points at a tab that does not exist.
import { useId, useMemo, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";
export interface Tab {
/** Unique within the set. Used in DOM ids, so it must not contain whitespace. */
id: string;
label: string;
panel: string;
}
export interface TabsProps {
/** Accessible name of the tab list. */
label: string;
tabs: readonly Tab[];
}
export function Tabs(props: TabsProps) {
const base = useId();
const [selected, setSelected] = useStore<string | undefined>(undefined);
const active = useMemo(() => {
const id = selected();
return props.tabs.some((tab) => tab.id === id) ? id : props.tabs[0]?.id;
});
const tabId = (id: string): string => `${base}-tab-${id}`;
const panelId = (id: string): string => `${base}-panel-${id}`;
const onKeyDown = (event: KeyboardEvent & { currentTarget: HTMLElement }): void => {
const count = props.tabs.length;
const current = props.tabs.findIndex((tab) => tab.id === active());
if (count === 0 || current === -1) return;
let next: number;
if (event.key === "ArrowRight") next = (current + 1) % count;
else if (event.key === "ArrowLeft") next = (current - 1 + count) % count;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = count - 1;
else return;
event.preventDefault();
const target = props.tabs[next];
if (target === undefined) return;
setSelected(target.id);
event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
};
return (
<div>
<div role="tablist" aria-label={props.label} onKeyDown={onKeyDown}>
<For each={props.tabs} key={(tab) => tab.id}>
{(tab) => (
<button
type="button"
role="tab"
id={tabId(tab().id)}
aria-selected={active() === tab().id}
aria-controls={panelId(tab().id)}
tabIndex={active() === tab().id ? 0 : -1}
onClick={() => setSelected(tab().id)}
>
{tab().label}
</button>
)}
</For>
</div>
<For each={props.tabs} key={(tab) => tab.id}>
{(tab) => (
<div
role="tabpanel"
id={panelId(tab().id)}
aria-labelledby={tabId(tab().id)}
hidden={active() !== tab().id}
tabIndex={active() === tab().id ? 0 : undefined}
>
{tab().panel}
</div>
)}
</For>
</div>
);
}
How the pieces fit:
useIdreturns an id that is stable for this position in the owner tree and safe inidandaria-*references. It throws outside an owner, so call it at the top of the component.- Roving tabindex: only the active tab has
tabindex="0", so Tab enters the tab list once and the next Tab leaves it. The visible panel hastabindex="0"so a keyboard user can reach panel content that has no focusable element. - The key handler lives on the tab list and relies on
keydownbubbling from the focused tab. It callspreventDefault()only for the four keys it handles, so Tab and everything else keep their default behavior. - Arrow keys call
setSelectedandfocus()in the same handler, so selection and focus never disagree. The handler runs inside the framework's event batch: the DOM is current when the event returns. hiddenis a boolean attribute:hidden={false}removes it.
Using it#
import { Tabs } from "./tabs";
export function Demo() {
return (
<Tabs
label="Account"
tabs={[
{ id: "profile", label: "Profile", panel: "Profile settings" },
{ id: "security", label: "Security", panel: "Security settings" },
{ id: "billing", label: "Billing", panel: "Billing settings" },
]}
/>
);
}