The <howto-tabs> component: accessible tabs without the panic

Tabbed interfaces are a staple of web design, but they are notoriously tricky to build accessibly. The <howto-tabs> custom element from the HowTo: Components project demonstrates a robust implementation that follows the ARIA Authoring Practices Guide while degrading gracefully when JavaScript is unavailable or fails to load.

The core behavior is simple: only one panel is visible at a time, all tabs stay visible, and the user can switch panels by clicking a tab or using the arrow keys, Home, and End. When JavaScript is disabled, however, the component's markup was designed so that panels remain visible, stacked between their corresponding tabs, which in turn function as basic headings.

Progressive enhancement and markup

The component is based on a pattern of alternating <howto-tab> and <howto-tabpanel> children inside a <howto-tabs> container. During a no-JS experience, each tab still carries the role="heading" in the source markup. When JavaScript runs, the element's connectedCallback() takes over to handle semantics. The custom element itself defines a set of key codes to support its keyboard handling:

const UP_KEY = 38;
const DOWN_KEY = 40;
const LEFT_KEY = 37;
const RIGHT_KEY = 39;
const HOME_KEY = 36;
const END_KEY = 35;

Architecture and shadow DOM

A shared template, rather than a fresh .innerHTML parse, is used to populate the shadow DOM for every <howto-tabs> instance. The component is stateless: no values are cached so runtime changes to the light DOM are always reflected. Event handlers that rely on this are explicitly bound in the constructor.

One of the more interesting design decisions is to reorder the visible elements through shadow DOM slots rather than physically moving the light DOM children. This is a deliberate choice for framework compatibility, as reordering children often breaks a framework's diffing logic.

Slots also introduce an elegant alternative to a MutationObserver when new children arrive. The slotchange event fires automatically when children are slotted, informing the component that it needs to link tabs and panels. The code also notes a legacy issue where slotchange did not fire during parser-based upgrades, so the handler is also invoked manually to clear that case. When all browsers support the newer behavior, the mitigation can be removed.

For the slot layout itself, the shadow DOM uses a structure that imports the shared <template>, providing the necessary <slot> elements to keep panels out of the way of dynamically slotted content.

Given that the component must react to its children, the connectedCallback() groups tabs and panels and makes sure there is exactly one active tab. The _onSlotChange() method fires whenever a child is added to or removed from one of the slots, triggering a call to _linkPanels(). That method walks all tab/panel pairs and:

  • Wires the aria-labelledby on panels back to their controlling <howto-tab>.
  • Checks whether any tab is marked as selected, and, if not, selects the first one.
  • Calls the internal _selectTab() method to synchronize visible state.

The implementation also permits a condition where an ARIA target does not exist. This is valid at intermediate steps during initialization, and the component explicitly handles that by checking for the panel's existence before acting on it, bailing out with an early return if it is missing.

Key handling mechanics

Keyboard navigation is contained inside the _onKeyDown() handler. Since the component listens on the container itself, it first checks if the event's target is one of the tab elements. If not, the event is originating inside a panel or on empty space, and the handler ignores it. It also refuses to run when the event is a modified keypress, such as those used by assistive technology.

A switch determines the next tab to activate. The helper methods used are interesting from a performance and readability angle:

  • _prevTab() uses findIndex() to locate the selected tab's index, wraps the index by adding tabs.length, then takes the modulus to loop back.
  • _firstTab() and _lastTab() are simple getters that return the first and last tab.
  • _nextTab() finds the next tab, also wrapping around the end.

After the target tab is identified, preventDefault() is called to stop the browser from also handling the arrow or Home/End key as a scroll or cursor movement. The handler then calls _selectTab() on the designated tab.

Click handling

The _onClick() handler is similarly guarded: clicks only result in tab switches if the originating element is itself a <howto-tab>. With that guard passed, the clicked tab is selected.

Selection and panels

Internally, all tabs are initially marked as not selected, and all panels are hidden during the reset phase. _selectTab() then:

  1. Clears all existing selections and visibility states again for consistency.
  2. Retrieves the panel tied to the new tab via _panelForTab().
  3. Handles the case where the panel does not exist, aborting the selection gracefully.
  4. Sets the proper state on the tab and panel.

Observationally, whether a panel is considered active is driven by aria-selected and hidden attributes rather than by adding or swapping CSS classes.

Tab and panel internals

Each <howto-tab> gets a unique, generated ID if the author does not supply one, thanks to a module-level counter. When a tab element upgrades, it swaps its role from the heading fallback to tab. The component carefully handles the tricky scenario where a property is set by a framework before a lazy-loaded definition appears:

The safeguard checks for instance values placed on the element before upgrade. Those values are copied down, once the definition arrives, so that they do not permanently shadow the class property setters' intended side effects.

A strong rule of thumb is applied to properties and attributes: they mirror one another, but the property setter itself is kept deliberately simple. It always sets the corresponding attribute instead of attempting more complicated side effects. Actually applying those side effects, such as toggling aria-selected, is reserved for the attributeChangedCallback. This separation makes the attribute/property life cycle much easier to reason about.

The final piece of the puzzle is <howto-tabpanel>, which is the component’s representation for the panel container. In combination with the tabs and the container, all elements work together to bring a standards-based, resilient tabbed interface to the web.