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.
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:
| Element | Role | Required/Useful Attributes |
|---|---|---|
| List | tablist | aria-orientation when vertical; accessible label |
| Trigger | tab | aria-selected, aria-controls, id, tabIndex |
| Panel | tabpanel | aria-labelledby, id, possibly tabIndex=0 |
Keyboard behavior depends on orientation:
| Key | Horizontal | Vertical |
|---|---|---|
| ArrowRight | next tab | usually ignored or optional |
| ArrowLeft | previous tab | usually ignored or optional |
| ArrowDown | optional | next tab |
| ArrowUp | optional | previous tab |
| Home | first tab | first tab |
| End | last tab | last tab |
| Enter/Space | activate focused tab in manual mode | activate focused tab |
Activation modes:
| Mode | Behavior | Good For |
|---|---|---|
| Automatic | focusing a tab activates it | fast/light panels |
| Manual | arrow keys move focus; Enter/Space activates | expensive 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.
| State | Owner | React Primitive | Notes |
|---|---|---|---|
| Selected value | Root or parent | controlled/uncontrolled state | Source of truth for active panel |
| Focused trigger | DOM/focus system | refs + event handlers | Usually not React state unless needed |
| Registered triggers | Root internal registry | ref/map | Needed for roving focus and disabled skip |
| Orientation | Root prop | prop/context | horizontal/vertical |
| Activation mode | Root prop | prop/context | automatic/manual |
| Trigger disabled | Trigger prop | registry metadata | Must be skipped by keyboard |
| IDs | Root/Trigger/Panel | useId + deterministic builder | Connect 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;
tabIndexwrong.
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;
valueanddefaultValuesemantics mixed;onValueChangenot 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
- WAI-ARIA APG — Tabs Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
- WAI-ARIA APG — Keyboard Interface: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/
- React —
useContext: https://react.dev/reference/react/useContext - React —
createContext: https://react.dev/reference/react/createContext
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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.