[]previous and next page

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.

For agentsUpdated

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.

src/tabs.tsxtsx
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:

  • useId returns an id that is stable for this position in the owner tree and safe in id and aria-* 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 has tabindex="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 keydown bubbling from the focused tab. It calls preventDefault() only for the four keys it handles, so Tab and everything else keep their default behavior.
  • Arrow keys call setSelected and focus() 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.
  • hidden is a boolean attribute: hidden={false} removes it.

Using it#

src/demo.tsxtsx
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" },
      ]}
    />
  );
}

Related: useId, useMemo, For, Index, fireEvent, userEvent.