Final StretchOrdered learning track

Build a Headless Tabs System

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 118

Build a headless tabs system from scratch with compound components, context, controlled/uncontrolled state, roving focus, keyboard navigation, ARIA semantics, testing, and production failure modes.

7 min read1392 words
PrevNext
Lesson 118123 lesson track102–123 Final Stretch
#react#hooks#component-composition#compound-components+4 more

Part 118 — Build a Headless Tabs System

Tabs look easy.

A naive implementation is only a few lines:

const [active, setActive] = useState('details');

Then a few buttons and panels.

That version usually fails when product requirements arrive:

  • controlled and uncontrolled usage;
  • keyboard navigation;
  • disabled tabs;
  • vertical orientation;
  • automatic vs manual activation;
  • stable IDs for aria-controls / aria-labelledby;
  • server rendering and hydration;
  • nested tabs;
  • design-system styling without locking markup;
  • focus behavior after dynamic tab removal;
  • analytics on tab change;
  • type-safe values;
  • testable behavior.

This part builds a headless tabs system from scratch.

The goal is not to compete with mature libraries.

The goal is to learn the architecture.


1. What “Headless Tabs” Means

Headless component means:

The component owns behavior and accessibility contract.
The consumer owns visual styling and often layout.

For tabs, the behavior contract is:

  • one active tab value;
  • tab triggers change value;
  • panels render content for value;
  • keyboard navigation moves focus among triggers;
  • selected tab and panel are connected by ARIA attributes;
  • disabled tabs are skipped;
  • consumer can style via className/data attributes;
  • consumer can choose controlled or uncontrolled ownership.

We are not building:

<Tabs theme="blue" rounded="lg" shadow="xl" />

We are building primitives:

<Tabs.Root defaultValue="overview">
  <Tabs.List aria-label="Case sections">
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="timeline">Timeline</Tabs.Trigger>
    <Tabs.Trigger value="evidence">Evidence</Tabs.Trigger>
  </Tabs.List>

  <Tabs.Panel value="overview">...</Tabs.Panel>
  <Tabs.Panel value="timeline">...</Tabs.Panel>
  <Tabs.Panel value="evidence">...</Tabs.Panel>
</Tabs.Root>

2. Accessibility Contract

The WAI-ARIA Tabs pattern defines the semantic structure:

tablist
  tab
  tab
  tab
tabpanel
tabpanel
tabpanel

Core attributes:

ElementRoleRequired/Useful Attributes
Listtablistaria-orientation when vertical; accessible label
Triggertabaria-selected, aria-controls, id, tabIndex
Paneltabpanelaria-labelledby, id, possibly tabIndex=0

Keyboard behavior depends on orientation:

KeyHorizontalVertical
ArrowRightnext tabusually ignored or optional
ArrowLeftprevious tabusually ignored or optional
ArrowDownoptionalnext tab
ArrowUpoptionalprevious tab
Homefirst tabfirst tab
Endlast tablast tab
Enter/Spaceactivate focused tab in manual modeactivate focused tab

Activation modes:

ModeBehaviorGood For
Automaticfocusing a tab activates itfast/light panels
Manualarrow keys move focus; Enter/Space activatesexpensive panels or remote-loaded content

If panels load expensive content, manual activation can prevent accidental work during arrow navigation.


3. State Topology

Tabs contain several kinds of state.

StateOwnerReact PrimitiveNotes
Selected valueRoot or parentcontrolled/uncontrolled stateSource of truth for active panel
Focused triggerDOM/focus systemrefs + event handlersUsually not React state unless needed
Registered triggersRoot internal registryref/mapNeeded for roving focus and disabled skip
OrientationRoot propprop/contexthorizontal/vertical
Activation modeRoot propprop/contextautomatic/manual
Trigger disabledTrigger propregistry metadataMust be skipped by keyboard
IDsRoot/Trigger/PaneluseId + deterministic builderConnect tab and panel

Do not put focus index in React state by default.

Focus is already stateful in the DOM.

Use refs and imperative focus only where keyboard navigation requires it.


4. Public API

We will expose:

<Tabs.Root
  value="overview"
  onValueChange={(value) => setValue(value)}
  defaultValue="overview"
  orientation="horizontal"
  activationMode="automatic"
>
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="overview">...</Tabs.Panel>
</Tabs.Root>

Root props:

type TabsRootProps = {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  orientation?: 'horizontal' | 'vertical';
  activationMode?: 'automatic' | 'manual';
  children: React.ReactNode;
};

Trigger props:

type TabsTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  value: string;
  disabled?: boolean;
};

Panel props:

type TabsPanelProps = React.HTMLAttributes<HTMLDivElement> & {
  value: string;
  forceMount?: boolean;
};

forceMount is useful when panel content must preserve internal state even when inactive.


5. Internal Context Shape

Tabs are a compound component.

Trigger and Panel need root state without prop drilling.

type TabsContextValue = {
  baseId: string;
  value: string | undefined;
  setValue: (value: string) => void;
  orientation: 'horizontal' | 'vertical';
  activationMode: 'automatic' | 'manual';
  registerTrigger: (entry: TriggerEntry) => () => void;
  getEnabledTriggers: () => TriggerEntry[];
};

type TriggerEntry = {
  value: string;
  disabled: boolean;
  ref: React.RefObject<HTMLButtonElement | null>;
};

We need a strict context hook:

const TabsContext = createContext<TabsContextValue | null>(null);

function useTabsContext(componentName: string) {
  const context = useContext(TabsContext);

  if (!context) {
    throw new Error(`${componentName} must be used inside <Tabs.Root>`);
  }

  return context;
}

Strict context gives a fast failure instead of silent broken ARIA.


6. Controlled/Uncontrolled State Hook

A reusable controllable state hook:

function useControllableState<T>(input: {
  value?: T;
  defaultValue?: T;
  onChange?: (value: T) => void;
}) {
  const [uncontrolledValue, setUncontrolledValue] = useState(input.defaultValue);
  const isControlled = input.value !== undefined;
  const value = isControlled ? input.value : uncontrolledValue;

  const setValue = useCallback(
    (next: T) => {
      if (!isControlled) {
        setUncontrolledValue(next);
      }
      input.onChange?.(next);
    },
    [isControlled, input.onChange],
  );

  return [value, setValue] as const;
}

This contract means:

If value is provided, parent owns selected tab.
If value is omitted, Tabs.Root owns selected tab.
In both modes, onValueChange is an event notification.

Failure mode:

<Tabs.Root value={value} defaultValue="a" />

Controlled and uncontrolled props should not be mixed carelessly.

In a mature library, warn in development if both are present.


7. Root Implementation

export function TabsRoot({
  value: controlledValue,
  defaultValue,
  onValueChange,
  orientation = 'horizontal',
  activationMode = 'automatic',
  children,
}: TabsRootProps) {
  const reactId = useId();
  const baseId = `tabs-${reactId.replace(/:/g, '')}`;
  const triggersRef = useRef<TriggerEntry[]>([]);

  const [value, setValue] = useControllableState<string>({
    value: controlledValue,
    defaultValue,
    onChange: onValueChange,
  });

  const registerTrigger = useCallback((entry: TriggerEntry) => {
    triggersRef.current = [...triggersRef.current, entry];

    return () => {
      triggersRef.current = triggersRef.current.filter(
        (candidate) => candidate !== entry,
      );
    };
  }, []);

  const getEnabledTriggers = useCallback(() => {
    return triggersRef.current.filter((entry) => !entry.disabled);
  }, []);

  const context = useMemo<TabsContextValue>(
    () => ({
      baseId,
      value,
      setValue,
      orientation,
      activationMode,
      registerTrigger,
      getEnabledTriggers,
    }),
    [
      baseId,
      value,
      setValue,
      orientation,
      activationMode,
      registerTrigger,
      getEnabledTriggers,
    ],
  );

  return <TabsContext.Provider value={context}>{children}</TabsContext.Provider>;
}

The registry is a ref, not state.

Why?

Because trigger registration should not force rerender every time a child mounts.

The registry is used for imperative focus navigation, not render output.


8. ID Helpers

ARIA needs stable relationships.

function getTriggerId(baseId: string, value: string) {
  return `${baseId}-trigger-${cssSafeValue(value)}`;
}

function getPanelId(baseId: string, value: string) {
  return `${baseId}-panel-${cssSafeValue(value)}`;
}

function cssSafeValue(value: string) {
  return value.replace(/[^a-zA-Z0-9_-]/g, '_');
}

The goal is not CSS safety alone.

The goal is stable ID generation across trigger and panel.


9. List Implementation

type TabsListProps = React.HTMLAttributes<HTMLDivElement>;

export function TabsList({ children, ...props }: TabsListProps) {
  const context = useTabsContext('Tabs.List');

  return (
    <div
      role="tablist"
      aria-orientation={context.orientation === 'vertical' ? 'vertical' : undefined}
      {...props}
    >
      {children}
    </div>
  );
}

The list does not own state.

It declares semantics and groups triggers.

The consumer should provide an accessible label:

<Tabs.List aria-label="Case sections">

or:

<h2 id="case-tabs-heading">Case sections</h2>
<Tabs.List aria-labelledby="case-tabs-heading">

10. Trigger Implementation

export function TabsTrigger({
  value,
  disabled = false,
  onClick,
  onKeyDown,
  ref: forwardedRef,
  ...props
}: TabsTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) {
  const context = useTabsContext('Tabs.Trigger');
  const localRef = useRef<HTMLButtonElement | null>(null);
  const selected = context.value === value;

  useComposedRef(localRef, forwardedRef);

  useEffect(() => {
    return context.registerTrigger({
      value,
      disabled,
      ref: localRef,
    });
  }, [context, value, disabled]);

  function activate() {
    if (!disabled) {
      context.setValue(value);
    }
  }

  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (event.defaultPrevented) return;
    activate();
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;

    handleTabsTriggerKeyDown(event, context, value);
  }

  return (
    <button
      type="button"
      role="tab"
      id={getTriggerId(context.baseId, value)}
      aria-selected={selected}
      aria-controls={getPanelId(context.baseId, value)}
      tabIndex={selected ? 0 : -1}
      disabled={disabled}
      data-state={selected ? 'active' : 'inactive'}
      data-disabled={disabled ? '' : undefined}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      ref={localRef}
      {...props}
    />
  );
}

tabIndex is important.

Only the selected tab should normally be in the tab sequence.

Arrow keys move focus inside the tablist.

This is called roving focus.


11. Composed Ref Helper

React 19 supports ref as a prop for function components. Many ecosystems still need React 18-compatible forwardRef patterns. The concept is the same: internal behavior and consumer ref must both receive the same node.

function assignRef<T>(ref: React.Ref<T> | undefined, value: T) {
  if (!ref) return;

  if (typeof ref === 'function') {
    ref(value);
    return;
  }

  ref.current = value;
}

function useComposedRef<T>(
  internalRef: React.MutableRefObject<T | null>,
  externalRef: React.Ref<T> | undefined,
) {
  return useCallback(
    (node: T | null) => {
      internalRef.current = node;
      assignRef(externalRef, node as T);
    },
    [internalRef, externalRef],
  );
}

In the trigger above, use it like this:

const composedRef = useComposedRef(localRef, forwardedRef);

return <button ref={composedRef} />;

If you forget ref composition, consumers cannot focus triggers from outside, and the registry may not see the DOM node.


12. Keyboard Navigation

function handleTabsTriggerKeyDown(
  event: React.KeyboardEvent<HTMLButtonElement>,
  context: TabsContextValue,
  currentValue: string,
) {
  const horizontal = context.orientation === 'horizontal';
  const vertical = context.orientation === 'vertical';

  const keyToDirection: Record<string, 'next' | 'previous' | 'first' | 'last' | undefined> = {
    ArrowRight: horizontal ? 'next' : undefined,
    ArrowLeft: horizontal ? 'previous' : undefined,
    ArrowDown: vertical ? 'next' : undefined,
    ArrowUp: vertical ? 'previous' : undefined,
    Home: 'first',
    End: 'last',
  };

  const direction = keyToDirection[event.key];

  if (direction) {
    event.preventDefault();
    focusTriggerByDirection(context, currentValue, direction);
    return;
  }

  if (context.activationMode === 'manual' && (event.key === 'Enter' || event.key === ' ')) {
    event.preventDefault();
    context.setValue(currentValue);
  }
}

Focus algorithm:

function focusTriggerByDirection(
  context: TabsContextValue,
  currentValue: string,
  direction: 'next' | 'previous' | 'first' | 'last',
) {
  const triggers = context.getEnabledTriggers();
  if (triggers.length === 0) return;

  const currentIndex = triggers.findIndex((entry) => entry.value === currentValue);

  let nextEntry: TriggerEntry | undefined;

  if (direction === 'first') {
    nextEntry = triggers[0];
  } else if (direction === 'last') {
    nextEntry = triggers[triggers.length - 1];
  } else {
    const delta = direction === 'next' ? 1 : -1;
    const start = currentIndex === -1 ? 0 : currentIndex;
    const nextIndex = (start + delta + triggers.length) % triggers.length;
    nextEntry = triggers[nextIndex];
  }

  nextEntry?.ref.current?.focus();

  if (nextEntry && context.activationMode === 'automatic') {
    context.setValue(nextEntry.value);
  }
}

Design decision:

Arrow keys move focus.
Automatic mode also activates focused tab.
Manual mode waits for Enter/Space.

13. Panel Implementation

export function TabsPanel({
  value,
  forceMount = false,
  children,
  ...props
}: TabsPanelProps) {
  const context = useTabsContext('Tabs.Panel');
  const selected = context.value === value;

  if (!selected && !forceMount) {
    return null;
  }

  return (
    <div
      role="tabpanel"
      id={getPanelId(context.baseId, value)}
      aria-labelledby={getTriggerId(context.baseId, value)}
      hidden={!selected}
      tabIndex={0}
      data-state={selected ? 'active' : 'inactive'}
      {...props}
    >
      {children}
    </div>
  );
}

Why forceMount?

Unmounting inactive panels is fine when panels are cheap and stateless.

But sometimes a panel contains:

  • form draft;
  • virtualized list scroll position;
  • expensive chart state;
  • video player;
  • uncontrolled widgets.

Then unmounting resets state.

forceMount lets the consumer preserve panel state while hiding inactive panels.


14. Export Namespace

export const Tabs = {
  Root: TabsRoot,
  List: TabsList,
  Trigger: TabsTrigger,
  Panel: TabsPanel,
};

Consumer usage:

<Tabs.Root defaultValue="summary" activationMode="manual">
  <Tabs.List aria-label="Case sections">
    <Tabs.Trigger value="summary">Summary</Tabs.Trigger>
    <Tabs.Trigger value="timeline">Timeline</Tabs.Trigger>
    <Tabs.Trigger value="evidence">Evidence</Tabs.Trigger>
    <Tabs.Trigger value="audit" disabled>
      Audit
    </Tabs.Trigger>
  </Tabs.List>

  <Tabs.Panel value="summary">Summary content</Tabs.Panel>
  <Tabs.Panel value="timeline">Timeline content</Tabs.Panel>
  <Tabs.Panel value="evidence">Evidence content</Tabs.Panel>
  <Tabs.Panel value="audit">Audit content</Tabs.Panel>
</Tabs.Root>

The consumer controls visuals:

[data-state='active'] {
  font-weight: 600;
}

[role='tab'][data-state='active'] {
  border-bottom: 2px solid currentColor;
}

Headless does not mean style-hostile.

It means styling is not hardcoded inside behavior.


15. Controlled Usage

function CaseTabsPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const selected = searchParams.get('tab') ?? 'summary';

  return (
    <Tabs.Root
      value={selected}
      onValueChange={(next) => {
        setSearchParams((params) => {
          params.set('tab', next);
          return params;
        });
      }}
    >
      <Tabs.List aria-label="Case sections">
        <Tabs.Trigger value="summary">Summary</Tabs.Trigger>
        <Tabs.Trigger value="timeline">Timeline</Tabs.Trigger>
        <Tabs.Trigger value="evidence">Evidence</Tabs.Trigger>
      </Tabs.List>

      <Tabs.Panel value="summary">...</Tabs.Panel>
      <Tabs.Panel value="timeline">...</Tabs.Panel>
      <Tabs.Panel value="evidence">...</Tabs.Panel>
    </Tabs.Root>
  );
}

Here the URL owns selected tab.

Tabs only emits intent.

This is the same principle as controlled inputs.


16. Dynamic Tabs

Dynamic tabs are where naive implementations break.

{tabs.map((tab) => (
  <Tabs.Trigger key={tab.id} value={tab.id} disabled={tab.disabled}>
    {tab.label}
  </Tabs.Trigger>
))}

Problems:

  • active tab removed;
  • all remaining tabs disabled;
  • focus points to unmounted node;
  • selected panel disappears;
  • URL contains invalid tab value.

Root can include a stabilization effect:

function useSelectedTabStabilizer(input: {
  value: string | undefined;
  setValue: (value: string) => void;
  getEnabledTriggers: () => TriggerEntry[];
}) {
  useEffect(() => {
    const enabled = input.getEnabledTriggers();
    if (enabled.length === 0) return;

    const selectedExists = enabled.some((entry) => entry.value === input.value);

    if (!selectedExists) {
      input.setValue(enabled[0].value);
    }
  }, [input.value, input.setValue, input.getEnabledTriggers]);
}

But be careful:

In controlled mode, auto-correcting selected value emits a parent change.
That may be right for URL tabs, but it is still a product decision.

A design-system primitive should document this behavior.


17. Automatic vs Manual Activation Trade-Off

Automatic activation feels fast when panels are local and cheap.

<Tabs.Root defaultValue="overview" activationMode="automatic" />

Manual activation is safer when each panel triggers server work.

<Tabs.Root defaultValue="overview" activationMode="manual" />

Example problem:

User presses ArrowRight three times.
Automatic mode activates three panels.
Each panel starts expensive query/load.

Mitigations:

  • manual activation;
  • prefetch adjacent panels;
  • cache panel data by tab value;
  • use Suspense boundaries carefully;
  • keep heavy query execution behind active value.

18. Data Fetching Inside Panels

Panel state ownership:

<Tabs.Panel value="timeline">
  <CaseTimeline caseId={caseId} />
</Tabs.Panel>

CaseTimeline can own its own query:

function CaseTimeline({ caseId }: { caseId: string }) {
  const query = useCaseTimeline({ caseId });
  // ...
}

If inactive panels unmount, queries may become inactive too.

Depending on query settings, cache may persist for gcTime, but the observer disappears.

Design rule:

If switching tabs should preserve local UI state, force-mount the panel or lift that state.
If switching tabs should not trigger hidden work, unmount inactive panels or disable hidden queries.

19. Nested Tabs

Nested tabs work if context is scoped.

<Tabs.Root defaultValue="outer-a">
  <Tabs.List>
    <Tabs.Trigger value="outer-a">Outer A</Tabs.Trigger>
    <Tabs.Trigger value="outer-b">Outer B</Tabs.Trigger>
  </Tabs.List>

  <Tabs.Panel value="outer-a">
    <Tabs.Root defaultValue="inner-a">
      <Tabs.List>
        <Tabs.Trigger value="inner-a">Inner A</Tabs.Trigger>
        <Tabs.Trigger value="inner-b">Inner B</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Panel value="inner-a">...</Tabs.Panel>
      <Tabs.Panel value="inner-b">...</Tabs.Panel>
    </Tabs.Root>
  </Tabs.Panel>
</Tabs.Root>

Each Tabs.Root provides its own context.

useContext reads the nearest provider.

This is why scoped compound component context is clean.


20. Testing Behavior

Use behavior-level tests.

it('activates a tab on click', async () => {
  render(
    <Tabs.Root defaultValue="a">
      <Tabs.List aria-label="Example tabs">
        <Tabs.Trigger value="a">A</Tabs.Trigger>
        <Tabs.Trigger value="b">B</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Panel value="a">Panel A</Tabs.Panel>
      <Tabs.Panel value="b">Panel B</Tabs.Panel>
    </Tabs.Root>,
  );

  await userEvent.click(screen.getByRole('tab', { name: 'B' }));

  expect(screen.getByRole('tab', { name: 'B' })).toHaveAttribute('aria-selected', 'true');
  expect(screen.getByRole('tabpanel')).toHaveTextContent('Panel B');
});

Keyboard test:

it('moves focus with arrow keys and activates in automatic mode', async () => {
  render(
    <Tabs.Root defaultValue="a" activationMode="automatic">
      <Tabs.List aria-label="Example tabs">
        <Tabs.Trigger value="a">A</Tabs.Trigger>
        <Tabs.Trigger value="b">B</Tabs.Trigger>
        <Tabs.Trigger value="c">C</Tabs.Trigger>
      </Tabs.List>
      <Tabs.Panel value="a">Panel A</Tabs.Panel>
      <Tabs.Panel value="b">Panel B</Tabs.Panel>
      <Tabs.Panel value="c">Panel C</Tabs.Panel>
    </Tabs.Root>,
  );

  screen.getByRole('tab', { name: 'A' }).focus();
  await userEvent.keyboard('{ArrowRight}');

  expect(screen.getByRole('tab', { name: 'B' })).toHaveFocus();
  expect(screen.getByRole('tab', { name: 'B' })).toHaveAttribute('aria-selected', 'true');
});

Manual activation test:

it('does not activate focused tab in manual mode until Enter', async () => {
  render(/* tabs */);

  screen.getByRole('tab', { name: 'A' }).focus();
  await userEvent.keyboard('{ArrowRight}');

  expect(screen.getByRole('tab', { name: 'B' })).toHaveFocus();
  expect(screen.getByRole('tab', { name: 'A' })).toHaveAttribute('aria-selected', 'true');

  await userEvent.keyboard('{Enter}');

  expect(screen.getByRole('tab', { name: 'B' })).toHaveAttribute('aria-selected', 'true');
});

Do not test internal context shape.

Test the observable accessibility contract.


21. Failure Modes

21.1 Tabs Work with Mouse but Not Keyboard

Cause:

  • no roving focus;
  • no arrow key handlers;
  • disabled tabs not skipped;
  • tabIndex wrong.

Fix:

Implement APG keyboard behavior and test with user-event keyboard input.

21.2 Active Panel Is Not Announced Correctly

Cause:

  • missing role="tab" / role="tabpanel";
  • missing aria-controls / aria-labelledby;
  • duplicate IDs;
  • hidden panel still visible to assistive tech unexpectedly.

Fix:

Generate stable IDs from root base ID + value.
Keep tab and panel linkage deterministic.

21.3 Controlled Tabs Ignore Parent State

Cause:

  • internal state always used;
  • value and defaultValue semantics mixed;
  • onValueChange not called consistently.

Fix:

Use controllable state contract.
Controlled value is source of truth when provided.

21.4 Trigger Registration Causes Rerender Loops

Cause:

  • registry stored in React state;
  • register function changes identity each render;
  • effect depends on unstable context object.

Fix:

Store registry in ref.
Memoize context value.
Keep register/getEnabledTriggers stable.

21.5 Dynamic Tab Removal Leaves Empty UI

Cause:

  • selected value no longer exists;
  • all tabs disabled;
  • URL references stale tab.

Fix:

Define selected-value stabilization behavior.
For URL-controlled tabs, canonicalize invalid tab to first allowed value or show 404-like state.

21.6 Heavy Panels Load During Arrow Navigation

Cause:

  • automatic activation with expensive panels;
  • panel query starts on mount/activation;
  • no prefetch/caching strategy.

Fix:

Use manual activation or prefetch intentionally.

22. Production Checklist

[ ] Root supports controlled and uncontrolled usage.
[ ] Trigger and panel IDs are stable and linked.
[ ] List has role=tablist and accessible label.
[ ] Triggers use role=tab, aria-selected, aria-controls, tabIndex.
[ ] Panels use role=tabpanel and aria-labelledby.
[ ] Arrow key navigation respects orientation.
[ ] Home/End keys work.
[ ] Disabled tabs are skipped.
[ ] Automatic/manual activation modes are explicit.
[ ] Registry does not cause rerender loops.
[ ] Nested tabs work through scoped context.
[ ] Dynamic tab removal has defined behavior.
[ ] Panels can preserve state with forceMount.
[ ] Tests assert observable role/ARIA/focus behavior.
[ ] Styling is exposed through data attributes, not behavior coupling.

23. Mental Model Summary

A good tabs primitive is not a state toggle.

It is a small protocol:

Root owns/receives selected value.
List declares tab group semantics.
Trigger emits selection/focus intent.
Panel renders selected content and links to trigger.
Context passes scoped coordination.
Refs handle focus navigation.
ARIA expresses the accessibility contract.

The architecture scales because each part has one job:

state ownership → Root
semantic grouping → List
user intent/focus → Trigger
content visibility → Panel
coordination → Context
keyboard imperative behavior → refs/registry
styling → consumer/data attributes

That is the headless component pattern in miniature.


References

Lesson Recap

You just completed lesson 118 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.