Notes · The Algebra of the Interface
budding
The Checkbox Is a State Machine
The indeterminate square isn't a third value of a boolean — it's a report the tree makes about itself. Model that one distinction right and the classic stuck-parent bug becomes impossible to write.
Bad programmers worry about the code. Good programmers worry about data structures and their relationships.
— Linus Torvalds, "Re: Licensing and the library version of git," git mailing list, July 27, 2006
Cite this
Mangalapilly, Y. J. (2026, July). The Checkbox Is a State Machine. Saṃhitā Notes. https://yesudeep.com/blog/the-checkbox-is-a-state-machine/ @online{mangalapilly2026the,
author = {Yesudeep Jose Mangalapilly},
title = {The Checkbox Is a State Machine},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {July},
url = {https://yesudeep.com/blog/the-checkbox-is-a-state-machine/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “The Checkbox Is a State Machine.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-checkbox-is-a-state-machine/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - The Checkbox Is a State Machine
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/the-checkbox-is-a-state-machine/
Y2 - 2026-08-12
ER - The opening piece of a new arc on the machines hiding in everyday interfaces. Frontend folklore says data structures and algorithms live somewhere else — in interviews, in databases, anywhere but the screen. This arc argues the opposite: the interface is where the machines are densest, and framework authors work hard to hide them from you. We start with the smallest specimen there is. By the end you will know what a finite-state machine actually is, why the browser's checkbox is one (with four internal configurations projected onto three visible glyphs), why a checkbox tree should store less state than it displays, how that fold becomes a bitset-backed, virtualized, lazy-data-aware control; why a license chooser is a separate finite-choice machine rather than a round checkbox tree; and — taking the control apart completely — how its other axes (disabled, focus, validity, hover) are not more states but separate machines composed alongside. The discrete-math ideas each mechanism rests on (folds, power sets, semilattices, and products with invariants) are defined in a box the first time they appear. The live demos on this page are the real thing — click them and watch the claims hold.
Before any theory, hold the finished thing. The tree below is the real control this piece derives — not an illustration of it. Toggle a few children and watch the parent's dash appear and resolve on its own; drag the slider and grow the tree to 65,536 leaves and it does not get slower, because each click recomputes only the toggled leaf's ancestor chain. Everything after this figure explains why it can behave this way, and why the version most products ship cannot.
A settings page has a parent checkbox over a nested list of children. Check a few children and the parent shows a small dash; check the rest and it should show a clean checkmark. It keeps showing the dash until a refresh clears it. The cause is that the parent's state was stored rather than derived from its leaves.
The machine underneath is the browser's, not a framework's. A checkbox is the smallest finite-state machine most interfaces ship, and the dash — the indeterminate state — is not a state in the way the other two are: it is a report computed on the way back up. That distinction, stored state versus derived report, decides whether a whole family of interface bugs can exist in the code at all.
In a checkbox tree, indeterminate is not authoritative state. It is a report that the children disagree — and reports should be derived from the facts that make them true.
The machine under the boolean
Ask an engineer what a checkbox holds and the answer is a boolean. The browser disagrees. A checkbox holds two booleans: its checkedness, and a separate, stranger flag called indeterminate. The HTML Standard is explicit that the two do not even look at each other: "An input element's indeterminateness is independent of its checkedness."
Indeterminate — a script-only property: it never appears as a markup attribute, and the flag itself is not serialized in form submission (the independent checkedness still decides whether the control contributes its name and value). It is styled with the :indeterminate CSS pseudo-class. Assistive technologies hear it as aria-checked"mixed"=.
Two booleans make four internal configurations, but your eye can only see three glyphs: unchecked, checked, and the dash. When indeterminate is true, the dash wins visually regardless of checkedness, so two different configurations look identical. That projection matters: the visible glyph alone does not always predict what native activation does next. The browser owns a four-state machine with a three-glyph output, not one tri-state boolean.
Here is one, live. This demonstration deliberately uses the canonical application representation of mixed: checked=false and indeterminate=true. It starts on the dash:
Click it. This canonical mixed configuration becomes checked. Click again: unchecked. Keep clicking as long as you like — you will never see the dash again. A raw native checkbox also permits the other mixed configuration, where checked=true; activating that one lands on unchecked instead.
A visible state you can leave but never enter
A finite-state machine is the least glamorous formal object in computer science: a finite set of states, an alphabet of events, and a transition function that says which event moves which state where. That is the entire definition. What makes the checkbox worth the name is that its transition table has a property you cannot see from the outside: it depends on who is asking.
Finite-state machine — states, events, transitions. Outputs can belong to states (a Moore machine) or to transitions (a Mealy machine); the distinction matters later in this arc, not yet. Learn more.
The user's alphabet has one letter: activate (click, Space, a tap). The spec's activation steps do two things: flip checkedness, then set indeterminateness to false. If a click's default action is canceled, the spec restores both earlier values; otherwise the transition table is:
| State before | checked | indeterminate | Visible | State after |
|---|---|---|---|---|
| unchecked | false | false | empty | checked |
| checked | true | false | check | unchecked |
| mixed | false | true | dash | checked |
| mixed | true | true | dash | unchecked |
The two mixed configurations have exits to different places because their hidden checkedness differs, but neither has an entrance under the user's alphabet. Only the programmatic alphabet — script changing the property pair — can put the machine behind the dash.
Most applications do not need both hidden mixed configurations. They choose a canonical representation — clear checkedness whenever they set indeterminate — and recover the smaller three-state machine below. That invariant is application policy, not a fact the native control guarantees. The machine starts in canonical mixed, and the programmatic alphabet is hidden. Press activate and watch it move; there is no solid arrow back into mixed. Then reveal the dashed programmatic entrances.
checked=false and indeterminate=true. Canonicalization merges the browser's two mixed configurations only after imposing that invariant.Two alphabets, two different transition graphs over the same three application states. The raw browser keeps one extra hidden configuration, but the important asymmetry survives: no uncanceled activation sequence enters the dash. It can only be the system's opinion, placed there by code. Which raises the real question: what is the system trying to say?
The tree that stores less than it shows
The dash earns its living in one place: a checkbox tree. A parent over children, children over grandchildren — settings panels, file pickers, permission grids, folder-sync dialogs. The parent shows checked when every descendant is checked, unchecked when none are, and the dash when they disagree.
The folklore implementation gives every node a stored state and writes synchronization in both directions: check a parent, loop down and check the children; check a child, look up and recompute the parent. Two code paths, mutating the same fields, triggered by each other. The stuck-parent bug from the opening is exactly one of those paths running half-way — an update that fired before a sibling's state landed, a guard that stopped the upward pass, an early return nobody remembers writing. You cannot fix it with better discipline, because the design has a structural flaw: the same fact is stored in two places. Whether "all of Alerts is on" is written both in the children and in the parent, and any two copies of a fact can disagree.
This bug family is industrial, not hypothetical. Public issue trackers across component libraries, platform toolkits, and IDE frameworks have carried it for years — one maintainer closed the stuck parent as unfixable by design; another project shipped so long with the defect that correcting it would break code depending on the bug, so the broken policy was versioned and kept. Duplicated authority is not a bug you patch. It is a design you leave.
The correct design stores less. One fact per leaf — checked or not. Nothing else. An interior node has no stored state at all: its display is a fold over the leaves beneath it — all checked, show checked; none, show unchecked; otherwise, show the dash. The two directions of "propagation" become two different kinds of thing:
- Down is a command. Activating a parent issues one command to every leaf beneath it: if all were checked, clear them; otherwise, set them all. That is the policy this tree chooses, not a platform guarantee; other controls may restore a remembered partial selection.
- Up is arithmetic. Nobody "updates" the parent. Its square is recomputed from the leaves whenever anything changes, the way a spreadsheet cell recomputes from its inputs.
Fold — the functional-programming staple (also called reduce): walk a collection, combining elements pairwise with a joining rule — write it — until one summary value remains. Our rule joins two tri-states: (agreement keeps the value) and when they differ — and mixed then absorbs everything (). A worked example over three leaves begins with agreement: The remaining unchecked leaf breaks that agreement: That absorption is also an executable stopping rule: a left-to-right fold can return as soon as its accumulator becomes mixed, whether because a child is already mixed or because two children disagree. No unread child can change the answer. The shortcut improves the common case, but not the worst case: a unanimous subtree, or one whose first disagreement is at the end, still requires every leaf. One edge needs a ruling rather than a rule: a subtree with no leaves has nothing to agree or disagree about, and the join has no value for "nothing" — by policy it reports unchecked, the safe reading of a vacuous fold (the generous reading, "all zero leaves are checked," is how one shipped tree renders an unloaded branch as fully checked). Learn more.
Summarize a checkbox subtree.
SUMMARIZE-SUBTREE(L)
Input: descendant-leaf values L[1:n]
Output: UNCHECKED, CHECKED, or MIXED
if n = 0
return UNCHECKED ▷ empty-subtree policy
first ← L[1]
for i ← 2 to n
if L[i] ≠ first
return MIXED
if first = true
return CHECKED
return UNCHECKEDSemilattice — a set with a join that is associative (), commutative (), and idempotent (). Our tri-state fold is one. Those three laws are exactly what makes a subtree's summary the same no matter which leaf you visit first, or how the tree is balanced — a rule without them would let two correct implementations disagree, which is how you would earn the stuck parent back. Learn more.
Ask a bus full of children one question: is everybody's seatbelt on? You can walk front-to-back or back-to-front. You can split the bus with a friend and combine your two answers. Asking the same child twice changes nothing. Those three freedoms are the whole reason two different people — or two different implementations — must land on the same answer. Take one away and the front-to-back walker and the bus-splitter can disagree, and that disagreement is the stuck parent, back again.
Nothing synchronizes because nothing is duplicated. The stuck parent is no longer a bug you fix; it is a sentence you cannot write — there is no field on the parent for a stale value to live in.
And the stuck parent has cousins — long-lived bug classes in public issue trackers, none hypothetical — each unwritten by the same discipline rather than fixed by more code:
| The bug you have shipped | Why it has no grammar here |
|---|---|
| A filter hides rows; checking a parent skips hidden descendants — or erases previously checked ones | A command names a subtree of the data, never of the view; filtering is a projection commands cannot see |
| Setting selection from code skips the synchronization clicks perform | One transition function behind every entry point — click, keyboard, initial value, API — so there is no second, weaker path |
| Selection evaporates when data is re-fetched or rows are recycled | State is keyed to durable leaf ids, not to view rows or object identity |
| The box looks mixed while the accessibility tree says checked | The glyph, the ARIA state, and the API answer are one derived value; two reports require two computations |
All of them are the same lesson wearing different costumes: every fact gets one owner, and everything else is a report.
Try it. One leaf starts checked, so the parent rests on its dash:
Check "Replies" and watch the parent's dash resolve to a checkmark — nobody set it; it was recomputed. Click "Alerts" twice and watch a command fan down (fill the subtree, then empty it) while the parent only ever reports. There is no click sequence that strands the parent, because the parent has nothing to strand.
Counting the states
How much is the dash summarizing? A tree with leaves has distinct configurations — every subset of leaves is one. Twenty leaves: over a million. The parent's square compresses its subtree's entire configuration space onto three glyphs. That compression is lossy on purpose, and the loss occasionally shows: Gmail's select-all checkbox marks the fifty conversations you can see, then has to add a banner — "Select all conversations that match this search" — because the summary glyph cannot carry the distinction between these fifty and everything. When a three-state summary meets a selection it cannot express, the interface has to grow words.
Power set — the set of all subsets of a set , written (or ). A set of elements has exactly subsets: each element is independently in or out, and choices across elements multiply to . So the "" here is not loose notation — the configurations of a leaf-set are its power set, one configuration per subset. Learn more.
A subset is a number. Store the leaf-set as a bitset — one bit per leaf — and the fold becomes arithmetic: number the leaves in depth-first order and every subtree owns a contiguous bit range, so its summary is a population count over that range (zero → unchecked, full → checked, anything else → the dash). The model then holds millions of leaves in kilobytes; what limits the tree you can show is the DOM, which is why large trees render only the visible window. Learn more.
The growth is easier to feel than to read about. Below, the row of checkboxes is a real leaf row — which makes it, literally, a binary number you can poke: every configuration of leaves is an -bit number, and yours is highlighted in the grid. Each cell is one configuration, colored by the glyph the parent would show: exactly one all-checked cell, exactly one all-unchecked cell, and every other cell the dash.
Now drag the slider.
Somewhere around a dozen leaves the grid stops being drawable; a few leaves later the enumeration stops being human; by forty leaves — one modest file tree — clicking through the configurations at five clicks a second outlasts recorded history. The parent checkbox summarizes that entire space every time you glance at it.
That is the honest way to see the indeterminate square: not a third value of a boolean, but the checkbox admitting it is a view over something bigger than itself.
Where the naive model breaks — and what the real one does
Every design in this arc gets this section. The difference is that each edge below has a name, and each name is a small amount of code the model absorbs — not a reason to abandon it.
Sometimes the third state is real, and then you store it. In an "on / off / inherit" permission control, the middle value is not a summary of children — it is a genuine stored fact meaning "defer to the parent policy." The test is the one that runs through this whole essay: can two copies of the fact disagree? The dash on a checkbox cannot (it is recomputed), so it is derived; "inherit" can (a child's and a parent's copy could drift), so it is stored. And storing it flips the transition table: where the checkbox's mixed is a state the user can leave but never enter, a real tri-state is one the user cycles into. Below is exactly that machine — click it and watch the dash arrive by pointer, the deliberate inverse of the checkbox above.
Lazy trees fold over what is loaded — so the fold learns to say "I don't know." If children arrive from a server on expand, the fold's domain is not resident, and claiming a parent is checked because its loaded children are is the same lie the stuck parent told. The fix is a fourth derived value, unknown, distinct from mixed: mixed means "the leaves I hold disagree," unknown means "I cannot see all the leaves." Both draw the dash — but they act differently. Activating a mixed node checks its leaves; activating an unknown one cannot finish locally, so it acts on the leaves it can see and escalates the rest to the source of truth — either loading first, or sending a predicate ("select everything under here," the Gmail "select all 2,431" escape hatch), which needs no enumeration at all. And note what unknown is not: it is not "loading." Whether a fetch is in flight is a separate machine — the same idle→loading→loaded async automaton that governs autocomplete and a progress bar — composed onto the node, not folded into the checkbox's value. When a new concern appears, the question is always whether it is a new value of this machine or a new machine beside it; buffering is a new machine.
A teacher counting heads for a field trip. "Some are here, some aren't" and "I can't see the back of the bus" both come out of her mouth as not everyone's aboard. Only one of them is fixed by looking harder. The dash says both things; the code has to know which one it meant.
Predicate — a rule that answers yes/no about each element, so a selection can be described by a condition instead of a list: "every message matching this search," "everything under this folder." When a set is too large — or too unloaded — to enumerate, the predicate is the only honest representation of "all of it," which is exactly why the escape-hatch banner exists. Learn more.
Partial summary — four display values alone are not an associative fold. The lawful carrier is evidence : is the set of definite choices observed below a node and says whether any descendant remains unresolved. Subtrees combine by set union and Boolean OR, in any order. Projection happens only at the boundary: two observed choices mean mixed; otherwise unresolved evidence means unknown; a singleton means that choice; an empty set follows an explicit empty-subtree policy. For binary checkboxes, two counts plus the unresolved flag are an efficient specialization of the same evidence. Never cache or combine the four projected display values: they have already discarded the evidence that makes the fold lawful.
Summarize a partially resident subtree without guessing.
SUMMARIZE-PARTIAL(C)
Input: child count triples C[1:n]
Output: UNCHECKED, CHECKED, MIXED, or UNKNOWN
checked ← 0
unchecked ← 0
unseen ← 0
for i ← 1 to n
checked ← checked + C[i].checked
unchecked ← unchecked + C[i].unchecked
unseen ← unseen + C[i].unseen
if checked > 0 and unchecked > 0
return MIXED
if unseen > 0
return UNKNOWN
if checked > 0
return CHECKED
return UNCHECKED ▷ includes the empty subtreeA round control is a different machine
A license dialog can resemble this tree while obeying a different algebra. If each license asks for exactly one of accept or decline, the authoritative value is a finite choice keyed by the semantic license identity. Several package rows may point to the same license; changing that one choice then appears to update every such row. That is not parent-checkbox propagation, and painting the checkbox slot round does not make it a radio group.
The honest composition is a navigation tree beside a map from license identity to one finite choice. If choices are independently editable per row, a treegrid with labeled Accept and Decline columns can make the grouping explicit. A parent “accept all” affordance is a command over a named subtree; its displayed value is still derived from child choices. Checkbox checkedness, row selection, and finite choice remain different coordinates with different accessibility contracts.
Finite-choice tree — a hierarchy used to navigate values from a finite set, such as accept | decline. Each semantic owner has at most one choice. The hierarchy may project or batch those choices, but it does not turn mutual exclusion into checkbox checkedness. The radio-group pattern specifies the choice invariant; the treegrid pattern is often the clearer composite when every row exposes several labeled choices.
Very large trees need a memoized fold — so the fold recomputes only the ancestor path. Recomputing a root over a hundred thousand leaves on every click is real work. Absorption lets that fresh scan stop once it proves mixed, but an all-checked or all-unchecked subtree still costs linear time. The incremental optimization avoids the scan altogether: retain the checked-leaf set as authority and cache a checked-leaf count per subtree. One transition changes the leaf and updates only its ancestor counts, so the cost is the tree's depth, not its size.
Toggle one leaf in an incremental checkbox tree.
TOGGLE-LEAF(id, bits, counts)
Input: leaf id, immutable bits, ancestor counts
Output: new bits, counts, and changed glyph ids
next-bits ← FLIP(bits, id)
if IS-CHECKED(next-bits, id)
delta ← 1
else
delta ← -1
next-counts ← counts
changed ← EMPTY-LIST
for each ancestor a from PARENT(id) to the root
before ← GLYPH(next-counts[a])
updated ← next-counts[a] + delta
next-counts ← PUT(next-counts, a, updated)
after ← GLYPH(updated)
if before ≠ after
changed ← APPEND(changed, a)
return (next-bits, next-counts, changed)The fair comparison is subtler than "the fold touches fewer nodes." A careful two-way implementation may also update only the changed leaf and its ancestor path. The difference is what those writes mean. In the two-way design, every node's displayed value is another authoritative fact, and the handlers must keep those facts in agreement. In the incremental fold, only the leaves are authoritative; the interior counts are disposable evidence about them. Delete every count and a fresh fold reconstructs exactly one answer.
Now step the same edit through both representations. The staged writes on the left are not a claim that every framework paints each intermediate frame; they expose states the model can represent if a listener is delayed, skipped, or interrupted. On the right, the leaf set and its cache path are one pure transition.
This qualification matters: a cache duplicates a derivable fact, so staleness becomes representable inside the implementation even though it remains absent from the public value model. The cache must be rebuildable from the leaves, and a property test should compare every cached summary with a fresh fold after arbitrary edit sequences. If that invariant is not worth maintaining, recompute instead. Drive the tradeoff below: slide the leaf count into the tens of thousands and toggle a box; the readout counts the nodes each edit recomputes.
"Depth is cheap" is measurable, so here are measurements — medians over up to 2,000 samples (fewer for the slow fresh folds) on balanced eight-way trees (Apple M2 Max, Node 26):
| Leaves | Toggle one leaf | Read any interior node | Fresh fold over every leaf |
|---|---|---|---|
| 4,096 | 0.46 µs | 0.08 µs | 7.3 µs |
| 32,768 | 1.42 µs | 0.08 µs | 58.4 µs |
| 262,144 | 2.13 µs | 0.13 µs | 466.6 µs |
| 1,048,576 | 2.58 µs | 0.13 µs | 1,854.7 µs |
The fresh fold grows exactly with the tree — 256× more leaves, 254× the time. The incremental edit barely moves — 256× more leaves, 5.6× the time, all of it depth and cache distance — and the interior read stays flat: constant work, the small drift pure cache distance. At a million leaves, one edit costs about 700× less than a single fresh fold, and a design that recomputes on render pays that fold per visible parent, per frame. Every incremental operation sits three or more orders of magnitude inside a 16 ms frame budget — while the fresh fold at a million leaves eats a tenth of the frame by itself.
On log–log axes those three growth laws are three shapes — a straight diagonal, a shallow drift, and a flat line:
An honest baseline. The fresh-fold column uses unanimous input deliberately: absorption — the early exit this essay introduced with the fold — would let a mixed tree answer after two leaves and make the baseline look better than it is. Unanimity is the fold's worst case, so the comparison is against the naive design at its most expensive honest reading. Benchmark baselines deserve the same skepticism as parent checkboxes: ask what the number is hiding.
A checkbox tree is more than checkboxes
The fold solves the tree's selection axis. It does not, by itself, make a production tree control. A row may be checked but collapsed, focused but outside the current viewport, loaded while some siblings are absent, or disabled for selection while still available for disclosure. Compress those facts into one state and the old synchronization problem returns under new names.
Keep the semantic coordinates separate, and give interaction phases their own small machines:
| Coordinate | Authority | Projection |
|---|---|---|
| selection | one bit per resident leaf | incremental subtree summary |
| disclosure | one bit per expandable loaded node | expanded rows enter the visible set |
| residency | loaded children + the source's unloaded summary | honest unknown where facts are absent |
| focus | one id | one roving tabindex set to "0" |
| viewport | start + bounded row count | only that visible window enters the DOM |
| filter | one fuzzy query | matches plus their ancestor paths |
| drag | idle or one lifted subtree session | current legal drop target |
| edit | ordinary editing or IME composition | settled filter work only |
| resource | request generation + load outcome | loading, failure, or accepted data |
Imagine transparent sheets laid over the same tree row. One sheet holds the checkmark, one the disclosure triangle, one whether the data has arrived, one the keyboard ring, one the visible window, and one the search match. The screen stacks the sheets. Changing one sheet must not secretly redraw the other five.
The lab below separates those sheets. Change one coordinate at a time, then watch the projection pipeline reduce the logical tree to the few rows that enter the DOM.
This separation changes the implementation in useful ways. The leaf ids receive stable ordinals, so 65,536 resident leaves occupy 2,048 32-bit words instead of 65,536 boxed booleans. Each node retains its resident-leaf count and checked-leaf count. Toggling one leaf flips one bit and repairs only its ancestor path; reading any interior value remains constant time. Checking a complete branch writes one contiguous leaf range. A disabled leaf is a boundary: a branch command changes every eligible descendant without silently changing it.
Activate a checkbox subtree across view, eligibility, and residency boundaries.
ACTIVATE-SUBTREE(node, summary, leaves, bounds)
Input: node, summary, leaves, selection bounds
Output: transition and any source request
if node = NIL or IS-SELECTION-DISABLED(node)
return NO-CHANGE
if summary = CHECKED
chosen ← UNCHECKED
else
chosen ← CHECKED
eligible ← RESIDENT-LEAVES-BELOW(node, leaves)
eligible ← EXCLUDE-DISABLED(eligible, bounds)
transition ← SET-ATOMICALLY(eligible, chosen)
transition ← RECOMPUTE-SUMMARIES(transition)
if HAS-UNRESIDENT-DESCENDANTS(node)
request ← SOURCE-REQUEST(node.id, chosen)
return (transition, request)
return (transition, NIL)Virtualization keeps a large logical collection but mounts only the small window a person can currently see. The WAI-ARIA tree-view pattern explains the corresponding focus and keyboard contract.
Virtualization happens after disclosure. First flatten only roots and descendants of expanded nodes; then project a bounded slice of that visible sequence. The DOM therefore grows with the viewport, not the data set. Because most siblings may not be mounted, each row supplies aria-level, aria-posinset, and aria-setsize explicitly. The dotted guides and chevrons explain hierarchy visually; those ARIA positions carry it when the lines cannot be seen.
Those attributes are necessary, not sufficient proof. The Authoring Practices pattern also describes child groups as owned by their parent tree item, while a flat virtual window may omit that ancestry from the DOM. Screen-reader behavior across Safari/VoiceOver, Firefox/NVDA, Chrome/JAWS, and zoomed or touch operation therefore remains an integration test, not a conclusion inferred from attributes alone. Likewise, ARIA exposes true, false, and mixed but no distinct unknown. Both derived values project to aria-checked"mixed"; =unknown needs an accessible description that says descendants are unloaded. aria-busy applies only while a request is actually in flight, not for durable uncertainty.
Important
Collapsed, filtered out, off-screen, and unloaded are four different meanings of invisible. Only the last means the model lacks the fact. Virtualization may omit a row from the DOM; it must never erase the row from the source of truth.
Keyboard behavior follows the same model, but the keys are not the model. The component exposes named semantic commands — tree-focus-next, tree-toggle, tree-disclose-or-child, tree-drag-lift, and the rest — and ships a WAI-ARIA keymap that binds them. Up and Down move through visible rows; Right opens a branch or enters its first child; Left closes it or returns to its parent; Home and End reach the boundaries; Space changes checkedness; printable text performs typeahead. An application may compile another contextual keymap — Vim j/k, Emacs C-n/C-p, or a user profile — against the same commands, using the same registry that drives the site's Which-Key surface. The behavior does not have to be copied into another event handler.
Note
The command is behavior; a key is only one binding. “Move to the next visible row” remains the same operation whether a profile names it Down, j, or C-n.
Move focus by typeahead.
TYPEAHEAD-FOCUS(key, prefix, rows, focus, elapsed)
Input: key, prefix, rows, focus, elapsed time
Output: focus, prefix, and renewed reset timer
if elapsed ≥ QUIET-INTERVAL
prefix ← EMPTY-STRING
prefix ← prefix + CASE-FOLD(key)
for each row in CIRCULAR-AFTER(rows, focus)
if STARTS-WITH(CASE-FOLD(row.label), prefix)
return (row, prefix, RESTART-TIMER())
return (focus, prefix, RESTART-TIMER())The filter input accepts a fuzzy subsequence, keeps every matching row and its ancestor path, and projects those paths open without overwriting the disclosure bits. Escape clears it; Down moves into the results. A wheel or trackpad over the tree advances the virtual window; the labeled range control jumps across long distances, while Page Up and Page Down provide the keyboard equivalent. The text field uses the same reusable search intent as the site's other query inputs: native search semantics, search return key, autocorrection and writing suggestions disabled, and a pure editing/composing machine. Intermediate IME events do not filter the tree, Enter flushes only committed text, an empty query settles immediately, and cancellation retires pending work. Those are input semantics, not checkbox-tree semantics, so the same machine belongs in every filter field rather than in each component's event handlers. If the tree is partial, the same query is emitted to the source and the resident result is marked incomplete; absence from loaded rows is not reported as absence from the data set. Focus, filtering, and checkedness remain independent. The disclosure button changes only the disclosure bit, never the selection bit.
Project a logical tree into a bounded set of DOM rows.
PROJECT-TREE(tree, disclosure, query, viewport)
Input: logical tree, disclosure, query, viewport
Output: current DOM rows and any source request
q ← CASE-FOLD(TRIM(query))
if q = EMPTY-STRING
rows ← DISCLOSED-PREORDER(tree, disclosure)
else
matches ← FUZZY-MATCH-RESIDENT(tree, q)
rows ← INCLUDE-REVEALING-ANCESTORS(matches)
request ← NIL
if HAS-UNRESIDENT-DESCENDANTS(tree)
request ← SOURCE-QUERY(q)
window ← SLICE(rows, viewport)
window ← ATTACH-TREE-METADATA(window)
return (window, request)Partial data needs one more boundary. A node whose childCount exceeds its loaded children is expandable even before those children arrive. Opening it emits a load request. Activating it emits a source-level "set unloaded descendants" request rather than pretending that the resident prefix is the whole branch. The host can replace the model when data arrives; until then the fold keeps the parent unknown. That is the difference between lazy rendering and an incomplete truth.
Now load the missing children. Before the load, the dashed cells are unknown rather than unchecked. After it, the same fold has enough evidence to distinguish checked, unchecked, and mixed.
Now make the tree a file system. A file or an entire directory subtree must move before a peer, after it, or inside another directory. That is not a mutation of the visible row list: virtualization has thrown most rows away, and a lazy directory may not have supplied its children. The control therefore validates the loaded topology, rejects moves into the source's own descendants, respects disabled boundaries, and emits a typed move intent to the source of truth. The host persists the move and replaces the immutable projection. An empty directory says explicitly that it accepts children; it cannot be mistaken for a file merely because both currently have zero loaded children.
Validate and request a source-owned subtree move.
REQUEST-MOVE(s, t, position, T, bounds)
Input: source id s, target id t, position
topology T, disabled boundaries bounds
Output: typed move intent or a rejection
source ← RESOLVE(T, s)
target ← RESOLVE(T, t)
if source = NIL or target = NIL
return REJECT(NOT-FOUND)
if IS-DESCENDANT(target, source)
return REJECT(CYCLE)
if position = INSIDE and not ACCEPTS(target)
return REJECT(CHILDREN-NOT-ACCEPTED)
if CROSSES-DISABLED(source, target, bounds)
return REJECT(DISABLED-BOUNDARY)
return MOVE-INTENT(s, t, position)Pointer dragging paints distinct before, inside, and after targets. The same operation is available without a pointer: Control/Command + Space lifts the focused subtree, Up and Down choose a target, Left and Right choose a structural position, Space or Enter drops, and Escape cancels. A live region announces the operation. This follows the keyboard-accessible drag model documented by React Aria, while keeping the move itself framework-free.
The structural half belongs behind a reusable disclosure-tree boundary. A grouped table can project its virtual rows into tr elements. A spreadsheet can reuse disclosure, focus, visible-row flattening, virtualization, and position metadata — but spreadsheet outlines themselves are ordered, normalized row and column interval forests. Their outline levels, collapsed state, and reasons a row is hidden are separate authority; checkbox checkedness and its fold do not transfer. Reusing the projection kernels while preserving that different algebra is the useful boundary.
The transfer is visible below. Move Reports once. No table row is dragged into a tree and no tree row is copied into a table; one source-owned structural intent replaces the hierarchy, and both views project it again.
This is a demanding industrial baseline, not a claim that every tree feature belongs in one machine. The comparison is easier to audit as a set of design lessons:
| Industrial control | Mechanism worth retaining | Boundary it clarifies |
|---|---|---|
| MUI X | reordering and file-explorer structure | a tree is more than selection |
| Ant Design | checking, lazy loading, tree lines, drag constraints | loading and disclosure are separate |
| Syncfusion | drag-edge scrolling and hover expansion | pointer sessions need timed effects |
| AG Grid | managed versus source-owned moves | remote structure belongs to the host |
Those comparisons exposed the delayed hover expansion, edge scrolling, move protocol, and keyboard drag session above. This control chooses source-owned moves: that is the only honest default when the data may be remote or partial. Label editing, multi-row selection, cross-tree copy, and variable-height measurement remain separate capabilities: they can compose with this structural controller without contaminating the checkedness fold.
The drivable tree above is the production control, not a look-alike: its rows are the bounded DOM window, its checkedness lives in the bitset, its parent glyphs come from the incremental fold, and its chevrons, hierarchy guides, fuzzy filter, roving focus, subtree drag, and bindable semantic commands use the same adapter applications import. Fixed-height rows are deliberate: they make the virtual offset exact without a measurement cache.
The hiding is usually correct. Framework and platform authors conceal these machines because most days you should not think about them, and the four handlers above are exactly the cases where you must — a genuine tri-state, an unresident subtree, a tree too large to fold naively. The time to pierce the abstraction is when one of those appears, or when the bug class does — a status answered two ways at once — not before. The reward for piercing it well is that the pierced version is still the small, honest machine; it just knows a few more of its own edges.
The whole control
We have followed one axis of the checkbox — its selection value — from a single box to a tree of a hundred thousand leaves. But ask what "state" a greyed-out, keyboard-focused, required-but-empty checkbox is in, and "unchecked" is only one coordinate of the answer. A real checkbox is a point in a product of independent dimensions, and telling them apart is the rest of the specification.
Product with invariants — for two sets and , the Cartesian product contains every pair . A real control often occupies only a subset: constraints relate the coordinates and make some tuples unreachable. Products keep independent concerns out of one giant union; invariants describe which combinations the platform actually permits. Learn more.
Here are the axes, and — the one question this whole essay turns on — whether each is stored, derived, or neither:
| Axis / relation | Values | Where it lives |
|---|---|---|
| selection | four internal pairs → three glyphs | checkedness + indeterminateness; tree interiors derive |
| enablement | enabled · disabled | a stored gate |
| constraints | optional · required | stored configuration |
| validity | valid · invalid | derived from selection, constraints, and candidacy |
| focus | focused · not | owned by the document |
| hover / active | on · off | owned by the pointer/interaction environment |
And here is the whole product, on the page — the browser's own checkbox in each state at once, so a claim below never floats free of the thing it describes. The last three cells are live: hover them, press them, and Tab through them.
Disabled removes the alphabet, not the value. A disabled checkbox is, in the spec's words, "not mutable": it accepts no input, is skipped by form submission, is barred from constraint validation, and leaves the tab order. In the language of the first section, disabling does not change which state the selection machine is in — it empties the machine's input alphabet. The transitions are still there on paper; nothing can fire them. That is exactly why a disabled box keeps whatever value it had: with no events, a state machine cannot move.
Validity is another fold, in miniature. Mark a checkbox required and the spec permits submission only when "the element's checkedness is true." So :invalid is not a state you set; it is the derived conjunction required and not checked — computed from two other facts, exactly the kind of thing that must never be stored. Store it and the checkbox-tree bug returns in a new costume: a field that stays red after the reason for red is gone.
Focus belongs to the document, not the box. The document maintains an active-element relation, and which element receives keyboard input is a fact about the page. Composite widgets such as menus and grids may add a roving-tabindex manager on top, but an ordinary native checkbox does not own one. The checkbox merely reports :focus; the browser decides, through the :focus-visible heuristic, whether to actually draw the ring (typically yes for the keyboard, no for a mouse click). Focus is environmental — composed onto the control, never a value it owns. The buffering state from the lazy-tree case was the same shape: a separate machine attached to the node, not a new value inside it.
Hover and active belong to the interaction environment. :hover and :active are CSS pseudo-classes driven moment to moment by pointing and activation. They are observable states, but not durable facts the selection machine should own. Copying them into application state would repeat the error the dash warned against — an environmental projection mistaken for authority.
Projection — a view computed from state for display, holding no authority of its own. The checkmark, the focus ring, the :invalid red: all projections of underlying facts. The discipline of the arc is that projections are written, never read back as truth.
Warning
readonly does not apply to a checkbox. The HTML Standard lists the states the attribute governs — text, search, URL, email, number, the date family, and the other textual states — and the checkbox is not among them. readonly exists to protect controls for which the standard defines read-only behavior. A checkbox does have a string value: when checked, it contributes its name and that value to form submission. What it lacks is a read-only checkbox state in HTML, so the attribute is silently ignored. To make one uneditable you either disabled it (and lose it from submission) or build a separately specified read-only interaction; ARIA can expose that semantics but does not change native behavior. A "read-only" checkbox a user can still toggle is a classic footgun.
The raw product is useful as a checklist, not as a count of reachable browser states. Validity depends on checkedness and constraints; disabled controls are barred from constraint validation; ordinary interaction does not focus a disabled control. Those relations carve away impossible tuples. The design lesson survives the smaller count: what looks like one pile of states is a handful of coordinates, some stored, some derived, and some owned by the environment. Keeping the coordinates separate makes each dependency explicit instead of pretending every combination is independent.
Lessons
- A native checkbox has four internal configurations projected onto three glyphs. Uncanceled activation flips checkedness and clears indeterminateness, so users can leave either mixed configuration but never enter one.
- Indeterminate is a report, not a state. In a tree, store one fact per leaf and derive every interior square as a fold. Commands go down; arithmetic comes up.
- Duplicated authoritative facts are where sync bugs live. If a value can be recomputed, derive it; if performance demands a cache, make the cache rebuildable and test it against a fresh fold.
- A summary glyph is lossy compression over configurations. When the interface must express what the summary cannot, it grows words — design for that moment rather than against it.
- Know when the model inverts: a middle value that users may legitimately choose is a stored state, not a report.
- The model's edges have names, and each name is a handler, not a retreat: a stored tri-state (
inherit), a derivedunknownfor the unresident subtree, an ancestor-only fold for the tree too large to recompute whole. When a new concern arrives — "is it loading?" — ask whether it is a new value of this machine or a new machine beside it. Buffering is a new machine. - Product coordinates organize independent concerns, but invariants determine which tuples a browser can actually reach. Do not multiply cardinalities until those dependencies are accounted for.
- A radio-shaped checkbox is still a checkbox to assistive technology. Model finite choice as its own machine, keyed by semantic owner, and expose it as a radio group or an explicit treegrid rather than a skin.
- Reuse mechanics at their true boundary. Spreadsheet outlines can share disclosure, focus, flattening, and virtualization with a tree; their ordered interval authority is not checkbox checkedness.
Practice
The checkpoints above are recognition; these two are production — work them on paper, where the model has to come out of your hands:
- Diagnosis. A code review shows a tree where each interior node has an
isCheckedfield updated by both a parent-click handler and a child-change listener. Name the bug family this code is guaranteed to host, and give the one-sentence fix. - Design. Your mail client shows fifty messages of a two-thousand- message search. Write the actual state type for select-all — what is stored, what is derived, where the "all 2,000" fact lives — before writing any handler.
- Transfer. A package installer shows a navigation tree in which several packages share one license. Specify the stable semantic owner, the finite choice map, and the command domain for “accept all.” Explain why a radio-shaped checkbox node would lose information.
References
- WHATWG. “Checkbox state.” HTML Standard. — the independent checked and indeterminate facts and the activation steps that clear indeterminateness
- MDN. “HTMLInputElement.indeterminate.” — the DOM property for a checkbox's indeterminate presentation
- MDN. “:indeterminate.” — the CSS pseudo-class for indeterminate controls
- W3C. “aria-checked.” WAI-ARIA. — how the mixed state reaches assistive technology
- W3C. “Checkbox pattern.” ARIA Authoring Practices Guide. — the mixed-state checkbox pattern outside native controls
- W3C. “Tree view pattern.” ARIA Authoring Practices Guide. — keyboard behavior and roving focus for trees
- W3C. “Radio group pattern.” ARIA Authoring Practices Guide. — the mutual-exclusion contract for finite choice
- W3C. “Treegrid pattern.” ARIA Authoring Practices Guide. — hierarchical rows containing separately labeled interactive cells
- MDN. “ARIA tree role.” — tree semantics and explicit position metadata
- MUI. “Tree item ordering.” MUI X. — a production contract for subtree reordering
- Adobe. “Tree.” React Aria. — accessible drag sessions and lazy tree data
- Ant Design. “Tree.” — constrained drop targets in a production tree control
- “Finite-state machine.” — the general machine model used in the essay
- “Fold.” — the general accumulation model contrasted with a state machine
How to cite
Mangalapilly, Y. J. (2026, July). The Checkbox Is a State Machine. Saṃhitā Notes. https://yesudeep.com/blog/the-checkbox-is-a-state-machine/ @online{mangalapilly2026the,
author = {Yesudeep Jose Mangalapilly},
title = {The Checkbox Is a State Machine},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {July},
url = {https://yesudeep.com/blog/the-checkbox-is-a-state-machine/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “The Checkbox Is a State Machine.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-checkbox-is-a-state-machine/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - The Checkbox Is a State Machine
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/the-checkbox-is-a-state-machine/
Y2 - 2026-08-12
ER - Webmentions
Annotations
Thank you — your note is held for review and will appear once approved.
Thank you — your note is published.
Please sign in below to leave a note.
