Skip to content

State

Pass checked with onCheckedChange and the parent owns the value — the switch will not move on its own. Pass defaultChecked and the component owns it. onCheckedChange fires in both modes, so the same handler works either way.

stateDiagram-v2
  [*] --> Deciding: mount

  Deciding --> Controlled: checked is defined
  Deciding --> Uncontrolled: checked is undefined

  state Controlled {
    [*] --> ParentOwns
    ParentOwns --> ParentOwns: setter is a no-op<br/>onChange still fires
  }

  state Uncontrolled {
    [*] --> ComponentOwns
    ComponentOwns --> ComponentOwns: setter updates internal state<br/>defaultChecked ignored after mount
  }

  note right of Deciding
    Decided once, never re-evaluated.
    Passing undefined at mount latches
    uncontrolled for the component's life.
  end note
The mode is chosen at mount from whether the controlled prop is defined, and never revisited.

Never pass an undefined checked

A checked of undefined on the first render makes the component uncontrolled for the rest of its life — it starts tracking its own state and ignores every value passed afterwards. When the value loads asynchronously, coalesce at the call site:

<Switch.Root checked={enabled ?? false} onCheckedChange={setEnabled} />

Deciding once is deliberate. Re-deriving the mode each render would let a parent that briefly passes undefined flip the component to uncontrolled and hand it state that has been frozen since mount — a bug that is very hard to see. Mixing the modes, or changing the default after mount, logs a development-only warning instead.

A headless component owns no class names, so the attribute name is the entire contract between behaviour and styling.

flowchart LR
  A["state<br/><small>{ checked: true, disabled: false }</small>"] --> B["stateAttributes<br/><small>one mapper per field</small>"]
  B --> C["data-checked<br/>(no data-disabled)"]
  C --> D["&lt;button data-checked&gt;"]
  D --> E["CSS<br/><small>.switch[data-checked] { … }</small>"]
Each state field runs through a declared mapper, so the attributes stay consistent across components.
.my-switch[data-checked] {
background: rebeccapurple;
}
.my-switch[data-checked] .my-switch-thumb {
transform: translateX(100%);
}

Attributes are matched on presence, not value — data-checked is always empty. Encoding state as a value would be a trap, because [data-checked] matches data-checked="false" too.

Nothing links the attribute a component emits to the selector that reads it. They meet for the first time in the rendered DOM, which means a rename breaks styling without breaking anything a test would normally notice — the component still emits an attribute, the CSS is still valid, and the control simply stops looking checked.

@arun-dev/ui guards its side with a test that diffs every [data-*] selector in its CSS against every attribute @arun-dev/headless emits. A selector that can never match fails it. If you write your own styling layer, the same check is worth having; it has already caught two regressions here, one of them a rename that every other test passed.

checked emits data-checked or data-unchecked, rather than one attribute and its absence.

:not([data-checked]) means “everything that is not checked”, so it silently absorbs any third state added later — a checkbox’s indeterminate would be styled as unchecked, with no error. Matching the state you mean keeps future states additive.