Tabs connect buttons to visible panels. In manual mode, arrows move focus while the selected panel
stays visible; Enter, Space, or click changes the panel. This example uses Summary and Activity to
show the difference between activeId and selectedId. Hidden panels stay mounted, so manual
activation does not itself defer their requests or subscriptions. Use routing for bookmarkable
destinations instead of local panels.
Build manually activated panels
import { html, component } from "@typed/template";
import * as Tabs from "@typed/ui/Tabs";
export const ProjectInspector = component(function* (id: string) {
const summaryId = `${id}-summary`;
const activityId = `${id}-activity`;
const state = yield* Tabs.makeState({ selectedId: summaryId, activationMode: "manual" });
const collection = yield* Tabs.makeCollection();
return html`<section>
<h2>Project inspector</h2>
${Tabs.List({ state, collection, label: "Project information", content: [
Tabs.Tab({ state, collection, id: summaryId, panelId: `${summaryId}-panel`,
content: "Summary" }),
Tabs.Tab({ state, collection, id: activityId, panelId: `${activityId}-panel`,
content: "Activity" }),
] })}
${Tabs.Panel({ state, id: `${summaryId}-panel`, tabId: summaryId,
content: html`<p>The project is ready for review.</p>` })}
${Tabs.Panel({ state, id: `${activityId}-panel`, tabId: activityId,
content: html`<ul><li>Draft created</li><li>Review requested</li></ul>` })}
</section>`;
});
The selected tab ID defaults the active ID. List owns tablist keyboard handling and hydration;
Tab is a native button with tab semantics and a collection registration; Panel references its tab
with aria-labelledby and hides when another tab is selected. Tab and panel IDs are different
identities connected in both directions. Keep both stable across server rendering and hydration.
Make panel activation a product decision
Automatic activation is the default: moving or focusing an enabled tab also changes selectedId.
Manual activation moves activeId but retains selectedId until Enter, Space, or click. The example
uses manual activation so arrowing to Activity does not hide Summary immediately. Both modes use
real DOM focus and a single active tab stop; neither puts active-descendant focus on the tablist.
Horizontal Left/Right or vertical Up/Down move according to orientation. Home/End choose bounds;
looping defaults true and RTL can reverse horizontal movement. Disabled registered tabs are skipped
by movement, and disabled tab focus/click handlers do not select them. Arrow behavior requires the
live collection or an explicit items array. Prefer the collection for rendered controls: it
supplies the actual elements for focusing and scrolling. An items array supports state movement
but is not a replacement for element registration.
Tabs.select(state, id) changes both selected and active identity, but is not a promise that browser
focus moved to that tab. Tabs.move(state, items, direction) is also a state transition; the List’s
collection-backed handler performs the DOM focus/scroll step. Keep that difference visible in tests.
The APG tabs pattern recommends automatic activation
when panel display is sufficiently immediate. Manual mode is often a better experience for panels
whose selection triggers remote loading.
Hidden does not mean disposed
Panel keeps its content mounted and changes hidden; it also has tabindex zero. The hidden
panel’s DOM and subscriptions still exist in the surrounding render scope. This preserves draft
input and avoids reconstruction, but it also means that a hidden panel’s polling or expensive stream
can continue. If selection should control resource lifetime, gate that work explicitly using the
selected-ID stream and Fx concurrency. Do not describe
CSS hiding as cancellation.
The MDN hidden reference explains the platform visibility attribute. Avoid author CSS that forces hidden panels back into layout. Do not put all panels inside the tablist: the list owns navigation among tab labels, while a panel may contain independent inputs and widgets with their own keys.
Reconcile dynamic tab sets
Closing a tab requires a domain decision about its successor. Choose the remaining neighbor and update selected/active IDs before leaving a state that references a removed tab. In manual mode, removing the active tab and removing the selected tab are different cases. The collection unregisters a removed element but does not invent that policy or implement a Delete-to-close interaction.
Browser checks should assert the active button, its tabindex, both ARIA relationships, and the visible panel after arrow and commit steps. In manual mode specifically assert that an arrow changes focus while leaving visibility unchanged. Check that a hidden panel cannot receive tab navigation, and that nested controls in the selected panel keep their own behavior.
Reuse a tab set safely
The first example accepts an ID prefix so each rendered inspector can own distinct tab/panel IDs.
For example, render ProjectInspector("left-project") beside ProjectInspector("right-project").
Each execution creates its own state and collection; every prefix must be unique on the page.
Never generate different random IDs during server and client rendering. Visible labels may match, but every aria-controls relationship must resolve within its own instance. The singular Tab entry point is an alias for this module. Consult the Tabs API for exact exports.