State
Controlled and uncontrolled
Section titled “Controlled and uncontrolled”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 noteNever 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.
State reaches CSS through data-*
Section titled “State reaches CSS through data-*”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["<button data-checked>"]
D --> E["CSS<br/><small>.switch[data-checked] { … }</small>"].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.
The name is the whole contract
Section titled “The name is the whole contract”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.
Why the negative form exists
Section titled “Why the negative form exists”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.