Build CoreOrdered learning track

Headless Components

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

Headless components as production-grade behavior primitives in React: logic/render separation, accessibility contracts, prop getters, state ownership, compound integration, design-system architecture, testing, and failure modes.

7 min read1368 words
PrevNext
Lesson 26123 lesson track24–67 Build Core
#react#headless-components#component-composition#accessibility+2 more

Part 026 — Headless Components

Headless component adalah komponen atau hook yang menyediakan behavior, state, accessibility semantics, dan interaction protocol tanpa mengunci tampilan visual.

Headless = brains without imposed looks.

Contoh:

const menu = useMenu()

return (
  <div>
    <button {...menu.getButtonProps()}>Options</button>

    {menu.open && (
      <div {...menu.getMenuProps()}>
        <button {...menu.getItemProps({ value: 'edit' })}>Edit</button>
        <button {...menu.getItemProps({ value: 'delete' })}>Delete</button>
      </div>
    )}
  </div>
)

Caller mengontrol markup dan styling. Headless primitive mengontrol state dan behavior contract.

Ini berbeda dari UI kit biasa.

Styled component library  → gives behavior + markup + style
Headless component        → gives behavior + accessibility + state protocol
Design system wrapper     → adds product-specific markup + style

1. Problem yang Diselesaikan

Enterprise UI sering memiliki kebutuhan yang bertentangan:

Behavior harus konsisten.
Accessibility harus benar.
Keyboard interaction harus lengkap.
Visual design harus fleksibel.
Markup harus cocok dengan product context.
Design system harus bisa evolve tanpa rewrite behavior.

Komponen styled biasa terlalu kaku.

<Select options={countries} />

Awalnya enak. Lalu muncul kebutuhan:

option dengan flag icon
option grouped by region
option disabled karena permission
searchable select
virtualized long list
custom empty state
custom error state
custom footer action
mobile sheet rendering
desktop popover rendering
audit-friendly selected display

API mulai berubah menjadi configuration monster.

<Select
  options={countries}
  renderOption={...}
  renderGroup={...}
  renderEmpty={...}
  renderFooter={...}
  mobileMode="sheet"
  virtualized
  permissionMap={...}
  searchable
  searchPlaceholder="Search country"
/>

Headless pattern memecah masalah:

Primitive owns behavior.
Consumer owns structure and visuals.
Design system can wrap primitive into opinionated components.

2. Mental Model

Headless primitive sits between browser interaction and consumer rendering.

It answers:

What is open?
What is selected?
Which item is active?
Which element owns focus?
Which keyboard event changes state?
Which ARIA attributes are required?
Which user event emits which command?

It does not answer:

What color is the button?
What spacing is used?
Which icon is displayed?
Which CSS framework is used?
Whether markup is div, button, li, or custom component where safe?

3. Headless Component vs Custom Hook

A custom hook can be headless.

function useDisclosure() {
  const [open, setOpen] = React.useState(false)

  return {
    open,
    openPanel: () => setOpen(true),
    closePanel: () => setOpen(false),
    togglePanel: () => setOpen((v) => !v),
  }
}

But production headless primitives usually do more:

state
commands
prop getters
id management
keyboard behavior
focus behavior
ARIA attributes
controlled/uncontrolled state
registration of items
escape hatches

A headless component may be implemented as:

1. hook-only API
2. render prop component
3. compound components
4. hybrid hook + context + compound API

Example hook-only:

const disclosure = useDisclosure()
return (
  <>
    <button {...disclosure.getButtonProps()}>Toggle</button>
    {disclosure.open && <div {...disclosure.getPanelProps()}>...</div>}
  </>
)

Example compound:

<Disclosure>
  <Disclosure.Button>Toggle</Disclosure.Button>
  <Disclosure.Panel>...</Disclosure.Panel>
</Disclosure>

Same behavior, different API surface.


4. Headless Component vs Compound Component

Compound component is a composition shape.

Headless component is a styling/rendering philosophy.

They often combine.

<Menu>
  <Menu.Button className="icon-button">Options</Menu.Button>
  <Menu.Items className="surface">
    <Menu.Item>{({ active }) => <button className={active ? 'active' : ''}>Edit</button>}</Menu.Item>
  </Menu.Items>
</Menu>

This is:

compound because Menu.Button/Menu.Items/Menu.Item coordinate
headless because visual styling is supplied by caller

Do not confuse the axes:

AxisQuestion
CompoundHow are multiple parts coordinated?
HeadlessWho owns visual rendering/styling?
ControlledWho owns state?
AccessibleDoes behavior match semantic/keyboard expectations?

A component can be:

compound but styled
headless but not compound
controlled but not headless
headless and compound and controllable

5. Accessibility Is Not Optional in Headless Components

Headless does not mean “unstyled div with state”.

If you provide a menu primitive, it must encode the menu interaction contract.

If you provide a tabs primitive, it must encode the tabs interaction contract.

If you provide a combobox primitive, it must encode combobox semantics, active option, input relationship, popup relationship, keyboard navigation, and selection behavior.

A headless primitive that leaves all accessibility details to consumers is not a production primitive. It is just a state hook.

Headless component must remove visual opinion, not behavioral responsibility.

6. Anatomy of a Headless Primitive

Let's define a minimal disclosure primitive.

Requirements:

controlled/uncontrolled open state
button opens/closes panel
button exposes aria-expanded
button points to panel through aria-controls
panel has stable id
consumer can pass own props
consumer event handlers are composed

API:

type DisclosureApi = {
  open: boolean
  setOpen: (open: boolean) => void
  toggle: () => void
  getButtonProps: (
    props?: React.ButtonHTMLAttributes<HTMLButtonElement>,
  ) => React.ButtonHTMLAttributes<HTMLButtonElement>
  getPanelProps: (
    props?: React.HTMLAttributes<HTMLDivElement>,
  ) => React.HTMLAttributes<HTMLDivElement>
}

Implementation:

function composeEventHandlers<E extends React.SyntheticEvent>(
  userHandler: ((event: E) => void) | undefined,
  internalHandler: (event: E) => void,
) {
  return (event: E) => {
    userHandler?.(event)
    if (!event.defaultPrevented) internalHandler(event)
  }
}

function useControllableState({
  value,
  defaultValue,
  onChange,
}: {
  value?: boolean
  defaultValue: boolean
  onChange?: (value: boolean) => void
}) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
  const controlled = value !== undefined
  const actualValue = controlled ? value : uncontrolledValue

  const setValue = React.useCallback((next: boolean) => {
    if (!controlled) setUncontrolledValue(next)
    onChange?.(next)
  }, [controlled, onChange])

  return [actualValue, setValue] as const
}

function useDisclosure({
  open,
  defaultOpen = false,
  onOpenChange,
}: {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
} = {}): DisclosureApi {
  const buttonId = React.useId()
  const panelId = React.useId()
  const [actualOpen, setOpen] = useControllableState({
    value: open,
    defaultValue: defaultOpen,
    onChange: onOpenChange,
  })

  const toggle = React.useCallback(() => {
    setOpen(!actualOpen)
  }, [actualOpen, setOpen])

  const getButtonProps = React.useCallback<DisclosureApi['getButtonProps']>((props = {}) => {
    return {
      ...props,
      id: props.id ?? buttonId,
      type: props.type ?? 'button',
      'aria-expanded': actualOpen,
      'aria-controls': panelId,
      onClick: composeEventHandlers(props.onClick, toggle),
    }
  }, [actualOpen, buttonId, panelId, toggle])

  const getPanelProps = React.useCallback<DisclosureApi['getPanelProps']>((props = {}) => {
    return {
      ...props,
      id: props.id ?? panelId,
      'aria-labelledby': buttonId,
      hidden: !actualOpen,
    }
  }, [actualOpen, buttonId, panelId])

  return React.useMemo(() => ({
    open: actualOpen,
    setOpen,
    toggle,
    getButtonProps,
    getPanelProps,
  }), [actualOpen, setOpen, toggle, getButtonProps, getPanelProps])
}

Usage:

function ProductDisclosure() {
  const disclosure = useDisclosure({ defaultOpen: true })

  return (
    <section>
      <button {...disclosure.getButtonProps({ className: 'section-title' })}>
        Product Details
      </button>

      <div {...disclosure.getPanelProps({ className: 'section-body' })}>
        Details content...
      </div>
    </section>
  )
}

This is headless because caller owns section, class names, content, spacing, and visual treatment.


7. Prop Getter Contract

Prop getter is a function that returns props to be spread onto a specific element.

<button {...getButtonProps()} />

It solves a real problem:

Consumer controls markup.
Primitive still injects required behavior and accessibility attributes.

A good prop getter:

merges caller props
composes event handlers
preserves caller className/style
preserves caller ref or composes it
sets required aria attributes
sets safe defaults
allows explicit escape hatch through preventDefault or options

A bad prop getter:

overwrites caller onClick
hides required behavior
exposes too many internals
requires caller to spread props in a fragile order
returns props incompatible with target element

Spread order matters.

Bad:

return {
  onClick: internalClick,
  ...props,
}

Here caller may accidentally erase internal behavior.

Also bad:

return {
  ...props,
  onClick: internalClick,
}

Here internal behavior erases caller behavior.

Better:

return {
  ...props,
  onClick: composeEventHandlers(props.onClick, internalClick),
}

8. Ref Composition

Headless primitives often need refs for focus management, measurement, or outside click detection.

Caller may also need refs.

Ref composition utility:

function composeRefs<T>(
  ...refs: Array<React.Ref<T> | undefined>
): React.RefCallback<T> {
  return (node) => {
    for (const ref of refs) {
      if (!ref) continue

      if (typeof ref === 'function') {
        ref(node)
      } else {
        ref.current = node
      }
    }
  }
}

Use inside getter:

const internalButtonRef = React.useRef<HTMLButtonElement | null>(null)

function getButtonProps(
  props: React.ButtonHTMLAttributes<HTMLButtonElement> & {
    ref?: React.Ref<HTMLButtonElement>
  } = {},
) {
  return {
    ...props,
    ref: composeRefs(internalButtonRef, props.ref),
    onClick: composeEventHandlers(props.onClick, toggle),
  }
}

In React 19, ref can be received as a prop by function components, but ecosystem/library compatibility may still require supporting older forwardRef consumers.


9. State Ownership in Headless Components

Headless primitives should be explicit about ownership.

Uncontrolled mode:
  primitive owns state
  caller passes defaultValue
  primitive emits changes

Controlled mode:
  caller owns state
  primitive receives value
  primitive emits requested changes

Example:

const menu = useMenu({
  open,
  onOpenChange: setOpen,
})

Do not mix ownership silently.

Bad:

function useMenu({ open, onOpenChange }) {
  const [internalOpen, setInternalOpen] = React.useState(false)

  // unclear which one wins
  const actualOpen = open || internalOpen
}

This breaks when open={false}.

Correct:

const controlled = open !== undefined
const actualOpen = controlled ? open : internalOpen

Invariant:

A given state field has one owner at a time.

10. Item Registration

Complex headless widgets need to know their items.

Examples:

menu item focus order
combobox option active index
tabs trigger/content matching
roving tab index
virtualized list selection

A registry pattern:

type ItemRecord = {
  id: string
  value: string
  disabled: boolean
  ref: React.RefObject<HTMLElement | null>
}

function useItemRegistry() {
  const itemsRef = React.useRef<ItemRecord[]>([])

  const register = React.useCallback((item: ItemRecord) => {
    itemsRef.current.push(item)

    return () => {
      itemsRef.current = itemsRef.current.filter((x) => x.id !== item.id)
    }
  }, [])

  const getItems = React.useCallback(() => {
    return itemsRef.current
      .slice()
      .sort((a, b) => {
        const aNode = a.ref.current
        const bNode = b.ref.current
        if (!aNode || !bNode) return 0
        const position = aNode.compareDocumentPosition(bNode)
        return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
      })
  }, [])

  return { register, getItems }
}

Why sort by DOM position?

React render order may not always represent final DOM order.
Conditional rendering, portals, and composition can change layout.
Keyboard navigation should follow actual DOM order when relevant.

Be careful: registry mutation lives in refs because registration is lifecycle state, not render state. If UI depends on item list, you may need external store or reducer updates.


11. Keyboard Orchestration

Headless primitives must encode keyboard behavior.

Example menu key handling:

function onMenuKeyDown(event: React.KeyboardEvent) {
  switch (event.key) {
    case 'Escape':
      event.preventDefault()
      closeMenu()
      focusButton()
      break
    case 'ArrowDown':
      event.preventDefault()
      focusNextItem()
      break
    case 'ArrowUp':
      event.preventDefault()
      focusPreviousItem()
      break
    case 'Home':
      event.preventDefault()
      focusFirstItem()
      break
    case 'End':
      event.preventDefault()
      focusLastItem()
      break
  }
}

Do not leave this to every consumer.

If each product team reimplements keyboard behavior, the organization does not have a headless primitive. It has copy-pasted state hooks.


12. Focus Management

Focus is state, but not React state in the usual sense.

Focus belongs to the browser, while React can command it through refs.

Headless primitive often needs focus commands:

focus trigger after menu closes
focus active item after menu opens
trap focus inside dialog
restore focus after modal unmount
skip disabled items
preserve focus when item list changes

Example:

function closeMenuAndRestoreFocus() {
  setOpen(false)
  requestAnimationFrame(() => {
    buttonRef.current?.focus()
  })
}

Why requestAnimationFrame may appear:

The target element may need to exist after state update commits.
Focus command must run after DOM is in expected shape.

But avoid sprinkling timing hacks. Prefer well-contained focus manager utilities.


13. ARIA Contract

ARIA should represent actual behavior.

Bad:

<div role="button" onClick={toggle}>Open</div>

This misses keyboard behavior and native semantics.

Better:

<button type="button" aria-expanded={open} onClick={toggle}>
  Open
</button>

Rule:

Prefer native semantic elements first.
Use ARIA to express relationships/states not available through native semantics.
Do not use ARIA as decoration.

For a real widget, follow established accessibility patterns.

Examples:

Tabs require tablist/tab/tabpanel relationships.
Combobox requires input-popup relationship and active option handling.
Menu button requires menu/menuitem roles and keyboard navigation.
Dialog requires focus containment and modal semantics.

Headless primitives should encode these relationships so consumers cannot easily forget them.


14. as and asChild Escape Hatches

Headless libraries often allow changing rendered element.

<Menu.Button as="a" href="/settings">
  Settings
</Menu.Button>

Or asChild style:

<Menu.Button asChild>
  <button className="icon-button">Options</button>
</Menu.Button>

This is powerful but dangerous.

Risks:

wrong semantic element
missing keyboard behavior
ref not forwarded
event handler not forwarded
required aria props dropped
invalid DOM nesting

Guideline:

Expose polymorphism only where you can preserve behavior contract.
Reject or document unsafe combinations.
Prefer native correct defaults.

For example, a trigger should usually render a button by default, not a div.


15. Building a Headless Tabs Primitive

Tabs is a good headless example because it has multiple parts and accessibility relationships.

State model

selectedValue
orientation
activationMode: automatic/manual
disabled triggers
registered triggers
registered panels

API shape

type TabsApi = {
  value: string
  setValue: (value: string) => void
  getListProps: (props?: React.HTMLAttributes<HTMLDivElement>) => React.HTMLAttributes<HTMLDivElement>
  getTriggerProps: (
    value: string,
    props?: React.ButtonHTMLAttributes<HTMLButtonElement>,
  ) => React.ButtonHTMLAttributes<HTMLButtonElement>
  getPanelProps: (
    value: string,
    props?: React.HTMLAttributes<HTMLDivElement>,
  ) => React.HTMLAttributes<HTMLDivElement>
}

Sketch

function useTabs({
  value,
  defaultValue,
  onValueChange,
  orientation = 'horizontal',
}: {
  value?: string
  defaultValue: string
  onValueChange?: (value: string) => void
  orientation?: 'horizontal' | 'vertical'
}): TabsApi {
  const [selectedValue, setSelectedValue] = useControllableStringState({
    value,
    defaultValue,
    onChange: onValueChange,
  })

  const baseId = React.useId()

  const getTriggerId = React.useCallback(
    (tabValue: string) => `${baseId}-trigger-${tabValue}`,
    [baseId],
  )

  const getPanelId = React.useCallback(
    (tabValue: string) => `${baseId}-panel-${tabValue}`,
    [baseId],
  )

  const getListProps = React.useCallback<TabsApi['getListProps']>((props = {}) => ({
    ...props,
    role: 'tablist',
    'aria-orientation': orientation,
  }), [orientation])

  const getTriggerProps = React.useCallback<TabsApi['getTriggerProps']>((tabValue, props = {}) => {
    const selected = selectedValue === tabValue

    return {
      ...props,
      id: getTriggerId(tabValue),
      role: 'tab',
      type: props.type ?? 'button',
      'aria-selected': selected,
      'aria-controls': getPanelId(tabValue),
      tabIndex: selected ? 0 : -1,
      onClick: composeEventHandlers(props.onClick, () => setSelectedValue(tabValue)),
    }
  }, [selectedValue, getTriggerId, getPanelId, setSelectedValue])

  const getPanelProps = React.useCallback<TabsApi['getPanelProps']>((tabValue, props = {}) => {
    const selected = selectedValue === tabValue

    return {
      ...props,
      id: getPanelId(tabValue),
      role: 'tabpanel',
      'aria-labelledby': getTriggerId(tabValue),
      hidden: !selected,
    }
  }, [selectedValue, getTriggerId, getPanelId])

  return React.useMemo(() => ({
    value: selectedValue,
    setValue: setSelectedValue,
    getListProps,
    getTriggerProps,
    getPanelProps,
  }), [selectedValue, setSelectedValue, getListProps, getTriggerProps, getPanelProps])
}

Usage:

function ProductTabs() {
  const tabs = useTabs({ defaultValue: 'overview' })

  return (
    <section>
      <div {...tabs.getListProps({ className: 'tab-list' })}>
        <button {...tabs.getTriggerProps('overview')}>Overview</button>
        <button {...tabs.getTriggerProps('billing')}>Billing</button>
        <button {...tabs.getTriggerProps('security')}>Security</button>
      </div>

      <div {...tabs.getPanelProps('overview')}>Overview content</div>
      <div {...tabs.getPanelProps('billing')}>Billing content</div>
      <div {...tabs.getPanelProps('security')}>Security content</div>
    </section>
  )
}

This is not a complete Tabs implementation. A production version must add arrow-key navigation, disabled tab handling, activation mode, and focus management.

But the skeleton shows the core headless contract.


16. Headless + Compound Hybrid

A more ergonomic API wraps the headless hook with context.

const TabsContext = React.createContext<TabsApi | null>(null)

function TabsRoot(props: {
  defaultValue: string
  children: React.ReactNode
}) {
  const tabs = useTabs({ defaultValue: props.defaultValue })

  return (
    <TabsContext.Provider value={tabs}>
      {props.children}
    </TabsContext.Provider>
  )
}

function useTabsContext() {
  const value = React.useContext(TabsContext)
  if (!value) throw new Error('Tabs parts must be used inside <TabsRoot>')
  return value
}

function TabsList(props: React.HTMLAttributes<HTMLDivElement>) {
  const tabs = useTabsContext()
  return <div {...tabs.getListProps(props)} />
}

function TabsTrigger({ value, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & {
  value: string
}) {
  const tabs = useTabsContext()
  return <button {...tabs.getTriggerProps(value, props)} />
}

function TabsPanel({ value, ...props }: React.HTMLAttributes<HTMLDivElement> & {
  value: string
}) {
  const tabs = useTabsContext()
  return <div {...tabs.getPanelProps(value, props)} />
}

Usage:

<TabsRoot defaultValue="overview">
  <TabsList className="tab-list">
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="billing">Billing</TabsTrigger>
  </TabsList>

  <TabsPanel value="overview">Overview content</TabsPanel>
  <TabsPanel value="billing">Billing content</TabsPanel>
</TabsRoot>

This is often the sweet spot for design systems:

headless behavior
compound ergonomics
caller-owned style
library-owned accessibility

17. Design System Layering

A production design system can layer components like this:

Example layers:

useTabs / TabsPrimitive
  ↓
DesignSystemTabs
  ↓
InvoiceStatusTabs
  ↓
InvoicePage

Each layer has a different responsibility.

Primitive layer

selected state
keyboard interaction
ARIA relationship
controlled/uncontrolled state

Design system layer

spacing
typography
variant
theme tokens
animation

Product layer

invoice-specific tab labels
permission rules
empty states
analytics events

Page layer

query params
server state
navigation
workflow orchestration

Do not collapse all layers into one mega-component.


18. Server State and Headless Components

Headless UI primitives should not usually own server state.

Bad:

<HeadlessCombobox endpoint="/api/users" />

This couples widget behavior to API transport.

Better:

const usersQuery = useUsersSearch(searchText)
const combobox = useCombobox({
  items: usersQuery.data ?? [],
  inputValue: searchText,
  onInputValueChange: setSearchText,
  selectedValue,
  onSelectedValueChange: setSelectedValue,
})

Widget owns interaction state:

open
active item
selected item
input text if configured
keyboard behavior

Server state cache owns:

fetching
caching
staleness
retry
invalidation

Product layer composes both.


19. URL State and Headless Components

Some widget state belongs in URL.

Example tabs:

const tab = searchParams.get('tab') ?? 'overview'

const tabs = useTabs({
  value: tab,
  onValueChange: (next) => setSearchParams({ tab: next }),
})

Here headless tabs are controlled by URL state.

This is correct when tab selection is:

shareable
bookmarkable
navigation-relevant
browser-back relevant

Do not hide URL state inside primitive. Primitive should accept controlled state and emit changes.


20. Headless Component Failure Modes

20.1 “Headless” but inaccessible

Symptom:

click works
keyboard broken
screen reader relationship missing
focus lost

Cause:

Primitive only modeled open/selected state, not widget semantics.

Fix:

Treat accessibility and keyboard behavior as part of primitive contract.

20.2 Prop getter is too magical

Symptom:

consumer cannot predict which props win
handlers silently overwritten
custom component integration breaks

Fix:

Document merge order.
Compose handlers.
Support ref composition.
Keep getter API narrow.

20.3 Primitive owns domain state

Symptom:

Select knows API endpoint
Tabs know invoice workflow
Dialog knows approval mutation

Fix:

Move domain/server/workflow state to product/page layer.
Primitive owns interaction behavior only.

20.4 Controlled/uncontrolled confusion

Symptom:

component sometimes ignores value=false
internal and external state drift
onChange fires but UI does not update

Fix:

Use explicit controllable-state helper.

20.5 Over-polymorphism

Symptom:

as='div' breaks button keyboard behavior
ref not forwarded
invalid semantics

Fix:

Limit polymorphism.
Prefer safe defaults.
Validate/document semantic requirements.

20.6 Context over-broadcast

Symptom:

all menu items rerender on every mouse move
large component tree becomes slow

Fix:

Split context.
Use external store/selectors for high-frequency state.
Avoid putting unstable object values in broad providers.

20.7 Registry memory leak

Symptom:

unmounted items still navigable
focus jumps to missing element

Fix:

Registration must return cleanup.
Cleanup must run reliably.
Use stable ids.

21. Testing Headless Components

Test through behavior and accessibility, not private state.

Example disclosure:

it('toggles panel visibility', async () => {
  const user = userEvent.setup()

  render(<ProductDisclosure />)

  const button = screen.getByRole('button', { name: /product details/i })
  expect(button).toHaveAttribute('aria-expanded', 'true')

  await user.click(button)

  expect(button).toHaveAttribute('aria-expanded', 'false')
})

Example keyboard test:

it('moves focus between menu items with arrow keys', async () => {
  const user = userEvent.setup()

  render(<ActionsMenu />)

  await user.click(screen.getByRole('button', { name: /options/i }))
  await user.keyboard('{ArrowDown}')

  expect(screen.getByRole('menuitem', { name: /edit/i })).toHaveFocus()
})

Test checklist:

roles and aria relationships
keyboard navigation
focus restoration
disabled item behavior
controlled mode
uncontrolled mode
custom props preserved
custom handlers composed
refs still work
unmount cleanup

22. Observability and Debugging

Headless primitives can be hard to debug because behavior is distributed through getters and parts.

Add debug affordances in development:

function useDebugHeadlessState(name: string, state: unknown) {
  React.useDebugValue(state, (value) => `${name}: ${JSON.stringify(value)}`)
}

Use meaningful component display names:

TabsRoot.displayName = 'Tabs.Root'
TabsList.displayName = 'Tabs.List'
TabsTrigger.displayName = 'Tabs.Trigger'
TabsPanel.displayName = 'Tabs.Panel'

For complex widgets, log semantic transitions in development, not low-level DOM noise.

menu.opened
menu.closed_by_escape
menu.item_focused
menu.item_selected
combobox.input_changed
combobox.option_selected

But do not put analytics in primitive unless it is explicitly an instrumentation primitive. Product layer should usually own analytics.


23. Performance Considerations

Headless components can accidentally rerender too much.

Common causes:

context value object recreated every render
all items consume same broad context
activeIndex changes on mouse movement
prop getters unstable
large list without virtualization

Mitigations:

memoize context value where useful
split static context from dynamic context
use item-level selectors for high-frequency state
use external store for large item collections
virtualize long lists
avoid storing derived values in state
profile before optimizing

Example split context:

const TabsConfigContext = React.createContext<TabsConfig | null>(null)
const TabsStateContext = React.createContext<TabsStateApi | null>(null)

Static config changes rarely. Dynamic state changes often. Splitting reduces unnecessary consumers.


24. Security and Trust Boundaries

Headless components often pass props into arbitrary consumer markup.

Be careful with:

dangerouslySetInnerHTML
untrusted labels
URL props
custom item renderers
portal containers
focus stealing

A headless component should not sanitize arbitrary product data unless that is its explicit responsibility. But its API should avoid encouraging unsafe rendering.

Bad:

<Option htmlLabel={serverProvidedHtml} />

Better:

<Option>{safeTextLabel}</Option>

If rich rendering is needed, caller owns it explicitly.


25. Documentation Requirements

Headless primitives need stronger docs than styled components because consumers wire markup.

Document:

required roles/semantics if not using default parts
which props must be spread
which elements are expected
keyboard behavior
focus behavior
controlled/uncontrolled API
ref requirements
composition examples
anti-patterns
accessibility notes
testing examples

Poor docs say:

Use getButtonProps.

Good docs say:

Spread getButtonProps on a focusable button-like trigger. Prefer a native button. If using a custom component, it must forward ref and props to a focusable element and preserve keyboard activation semantics.

26. Decision Matrix

RequirementPrefer
One simple reusable behaviorCustom Hook
One scoped render zoneRender Prop
Multi-part widget with flexible stylingHeadless Compound Component
Product-specific styled componentDesign System Wrapper
Highly constrained visual componentStyled Component
Complex workflow transitionsState Machine + View Components
Large shared state with selective subscriptionExternal Store

27. Production Checklist

Before shipping a headless primitive:

Does it own only interaction behavior, not domain/server state?
Does it expose controlled and uncontrolled modes clearly?
Does it preserve caller props and handlers?
Does it compose refs?
Does it use native semantics where possible?
Does it encode required ARIA relationships?
Does it implement keyboard behavior expected for the widget?
Does it manage focus correctly?
Does it clean up registered items?
Does it avoid broad rerender propagation?
Does it document required spread points?
Does it have behavior tests and accessibility assertions?
Does it have escape hatches without breaking invariants?

28. Practical Exercise

Build a headless menu primitive.

Required behavior:

closed/open state
button toggles menu
Escape closes menu and restores focus to button
ArrowDown opens menu and focuses first item
ArrowUp opens menu and focuses last item
Home/End move focus
disabled item is skipped
Enter/Space selects active item
outside click closes menu
controlled and uncontrolled open mode

Build three APIs:

1. useMenu hook with prop getters
2. <Menu>{api => ...}</Menu> render prop
3. <Menu.Root><Menu.Button /><Menu.Items /><Menu.Item /></Menu.Root> compound API

Then evaluate:

Which API is most flexible?
Which API is hardest to misuse?
Which API requires the most documentation?
Which API is best for product teams?
Which API is best for a design system package?

29. Key Takeaways

Headless components are not about removing CSS. They are about separating responsibilities.

Primitive owns interaction semantics.
Design system owns visual language.
Product owns domain meaning.
Page owns orchestration.

A weak headless primitive exposes state and leaves accessibility to consumers.

A strong headless primitive exposes a safe protocol:

state
commands
prop getters
focus management
keyboard behavior
ARIA relationships
controlled ownership
escape hatches

The point is not flexibility at any cost. The point is flexibility inside a correct behavioral envelope.


References

Lesson Recap

You just completed lesson 26 in build core. 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.