Lesson 19 / 25
Headless Components
Detaching behavior from view; writing the option list as a pure state machine, deriving accessibility attributes from state, testing it with event sequences, and using the same behavior with two views.
Contents
The filter panel has a list for selecting a station. The list’s behavior is detailed: navigating with arrow keys, skipping a disabled item, wrapping around at the end of the list, jumping to the first and last item, going to a matching item when a letter is pressed, selecting, canceling. This behavior also carries an accessibility contract — which item is active, whether the list is open, and which item is selected all have to be announced to assistive technology.
The view, however, differs everywhere. In the filter panel it is a dropdown attached to a button, on a wide screen it is a list that stays open in a side panel, in the measurement table’s header it is an inline selector. Embedding the behavior in one component means collapsing these three views into a single view.
Behavior Is Not a Component
A headless component is a unit that carries behavior and an accessibility contract but produces no view at all. It gives back two things: a state to read and attributes to attach to markup. It picks no element, writes no class, defines no style.
It is one step past the separation from the previous lesson. Presentational and container separation separated data from view; here what gets separated is behavior. What remains is three independent layers: one that obtains the data, one that runs the behavior, and one that produces the view.
The carrier of the behavior is a state machine. A reducer that fits the pure function definition from the Programming Fundamentals course — it takes a state and an event, returns a new state — can carry the whole behavior. The timer, DOM access, and network call stay outside this function.
// headless.mjs — the option list's behavior: a pure state machine, no view export const OPTIONS = [ { value: "upper", label: "Upper Slope" }, { value: "middle", label: "Middle Terrace" }, { value: "lower", label: "Lower Slope", disabled: true }, { value: "summit", label: "Summit" }, ]; export const initialState = (selected = null) => ({ open: false, active: -1, selected, buffer: "", lastLetterTime: -Infinity }); const isSelectable = (o, i) => i >= 0 && i < o.length && !o[i].disabled; const edge = (o, fromStart) => (fromStart ? o.map((_, i) => i) : o.map((_, i) => o.length - 1 - i)).find((i) => isSelectable(o, i)) ?? -1; const advance = (o, startIndex, direction) => { if (startIndex < 0) return edge(o, direction > 0); for (let step = 1; step <= o.length; step++) { const i = (startIndex + direction * step + o.length * step) % o.length; if (isSelectable(o, i)) return i; } return -1; }; // Pure reducer: (state, event) -> new state. No side effect, no timer, no DOM. export function reduce(state, event, options = OPTIONS) { const openList = () => { const selectedIndex = options.findIndex((o) => o.value === state.selected); return { ...state, open: true, buffer: "", active: isSelectable(options, selectedIndex) ? selectedIndex : edge(options, true), }; }; const closeList = () => ({ ...state, open: false, active: -1, buffer: "" }); switch (event.type) { case "open": return state.open ? state : openList(); case "close": return closeList(); case "mark": return state.open && isSelectable(options, event.index) ? { ...state, active: event.index } : state; case "key": switch (event.key) { case "ArrowDown": return state.open ? { ...state, active: advance(options, state.active, +1) } : openList(); case "ArrowUp": return state.open ? { ...state, active: advance(options, state.active, -1) } : openList(); case "Home": return state.open ? { ...state, active: edge(options, true) } : state; case "End": return state.open ? { ...state, active: edge(options, false) } : state; case "Escape": return closeList(); case "Enter": case " ": if (!state.open) return openList(); if (!isSelectable(options, state.active)) return state; return { ...closeList(), selected: options[state.active].value }; default: return state; } case "letter": { const next = event.time - state.lastLetterTime > 500 ? event.letter : state.buffer + event.letter; const lower = (m) => m.toLocaleLowerCase("en-US"); const found = options.findIndex((o, i) => isSelectable(options, i) && lower(o.label).startsWith(lower(next))); return { ...state, open: true, buffer: next, lastLetterTime: event.time, active: found === -1 ? state.active : found, }; } default: return state; } } // Accessibility attributes derived from state: still not a view, but a contract. export const aria = (state, root, options = OPTIONS) => ({ trigger: { "aria-haspopup": "listbox", "aria-expanded": String(state.open), "aria-controls": `${root}-list`, }, list: { id: `${root}-list`, role: "listbox", tabindex: -1, "aria-activedescendant": state.open && state.active >= 0 ? `${root}-option-${state.active}` : "", }, option: (i) => ({ id: `${root}-option-${i}`, role: "option", "aria-selected": String(options[i].value === state.selected), ...(options[i].disabled ? { "aria-disabled": "true" } : {}), }), });
The state has five fields and none of them belongs to the view: whether the list is open, which item is active, which value is selected, what the typeahead buffer holds, and when the last letter was entered. Even time is a field — the reducer does not read the clock, it receives it inside the event. This makes the test independent of time.
Accessibility attributes are written as a derivative of state. The active item does not
take focus; the list stays focused and the active item’s identity is announced through
aria-activedescendant. This is another shape of the roving tabindex arrangement from The
Browser and the Web Platform course: a single stop in keyboard order, navigation with
arrow keys inside the list.
Same Behavior, Two Views
The following file goes in the same directory as headless.mjs from the previous block.
// headless-trace.mjs — the same state machine, two separate views import { OPTIONS, initialState, reduce, aria } from "./headless.mjs"; const events = [ { type: "key", key: "ArrowDown" }, { type: "key", key: "ArrowDown" }, { type: "key", key: "ArrowDown" }, { type: "letter", letter: "s", time: 1000 }, { type: "key", key: "Home" }, { type: "key", key: "ArrowUp" }, { type: "key", key: "Enter" }, { type: "key", key: "ArrowDown" }, { type: "key", key: "Escape" }, ]; let state = initialState(); console.log("event open active active label selected buffer"); console.log(`${"(start)".padEnd(20)} ${String(state.open).padEnd(5)} ${String(state.active).padStart(6)} ${"-".padEnd(15)} ${String(state.selected).padEnd(8)} "${state.buffer}"`); for (const event of events) { state = reduce(state, event); const eventLabel = event.type === "key" ? `key ${event.key}` : event.type === "letter" ? `letter "${event.letter}"` : event.type; const activeLabel = state.active >= 0 ? OPTIONS[state.active].label : "-"; console.log(`${eventLabel.padEnd(20)} ${String(state.open).padEnd(5)} ${String(state.active).padStart(6)} ${activeLabel.padEnd(15)} ${String(state.selected).padEnd(8)} "${state.buffer}"`); } // --- Same state, two views --- const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) }); const text = (v) => ({ name: "#text", attrs: {}, children: [], text: v }); const printTree = (node, depth = 0) => { if (node.text !== undefined) return [`${" ".repeat(depth)}"${node.text}"`]; const attrsText = Object.entries(node.attrs).filter(([, v]) => v !== "").map(([a, v]) => ` ${a}="${v}"`).join(""); return [`${" ".repeat(depth)}<${node.name}${attrsText}>`, ...node.children.flatMap((c) => printTree(c, depth + 1))]; }; // View A: dropdown attached to a button. const Dropdown = (state, root) => { const a = aria(state, root); const selected = OPTIONS.find((o) => o.value === state.selected); return element("div", { class: "dropdown" }, element("button", { type: "button", ...a.trigger }, text(selected?.label ?? "Select a station")), state.open && element("ul", { ...a.list, class: "dropdown-list" }, ...OPTIONS.map((o, i) => element("li", { ...a.option(i) }, text(o.label))))); }; // View B: side panel, always open. const SidePanel = (state, root) => { const a = aria({ ...state, open: true }, root); return element("nav", { class: "side-panel" }, element("h2", { id: `${root}-heading` }, text("Station")), element("ul", { ...a.list, "aria-labelledby": `${root}-heading`, class: "panel-list" }, ...OPTIONS.map((o, i) => element("li", { ...a.option(i) }, text(o.label))))); }; let demoState = initialState(); for (const event of [{ type: "open" }, { type: "key", key: "ArrowDown" }]) demoState = reduce(demoState, event); console.log("\nview A (dropdown), state: open, active =", demoState.active); console.log(printTree(Dropdown(demoState, "station")).join("\n")); // Compare the two views: role and state attributes are the same, element names and classes differ. const walk = (d, c = []) => { if (d.text === undefined) { c.push(d); d.children.forEach((x) => walk(x, c)); } return c; }; const signature = (root, on) => walk(root).map((d) => { const role = d.attrs.role ?? d.name; const stateAttrs = Object.entries(d.attrs) .filter(([a]) => a.startsWith("aria-")) .map(([a, v]) => `${a}=${String(v).replace(on, "*")}`).sort().join(","); return `${role}${stateAttrs ? "[" + stateAttrs + "]" : ""}`; }); const a = signature(Dropdown(demoState, "station"), /station/g); const b = signature(SidePanel(demoState, "station-side"), /station-side/g); console.log("\nview A role/state signature:", JSON.stringify(a)); console.log("view B role/state signature:", JSON.stringify(b)); console.log("element names:", JSON.stringify(walk(Dropdown(demoState, "station")).map((d) => d.name)), "/", JSON.stringify(walk(SidePanel(demoState, "station-side")).map((d) => d.name)));
event open active active label selected buffer
(start) false -1 - null ""
key ArrowDown true 0 Upper Slope null ""
key ArrowDown true 1 Middle Terrace null ""
key ArrowDown true 3 Summit null ""
letter "s" true 3 Summit null "s"
key Home true 0 Upper Slope null "s"
key ArrowUp true 3 Summit null "s"
key Enter false -1 - summit ""
key ArrowDown true 3 Summit summit ""
key Escape false -1 - summit ""
view A (dropdown), state: open, active = 1
<div class="dropdown">
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-controls="station-list">
"Select a station"
<ul id="station-list" role="listbox" tabindex="-1" aria-activedescendant="station-option-1" class="dropdown-list">
<li id="station-option-0" role="option" aria-selected="false">
"Upper Slope"
<li id="station-option-1" role="option" aria-selected="false">
"Middle Terrace"
<li id="station-option-2" role="option" aria-selected="false" aria-disabled="true">
"Lower Slope"
<li id="station-option-3" role="option" aria-selected="false">
"Summit"
view A role/state signature: ["div","button[aria-controls=*-list,aria-expanded=true,aria-haspopup=listbox]","listbox[aria-activedescendant=*-option-1]","option[aria-selected=false]","option[aria-selected=false]","option[aria-disabled=true,aria-selected=false]","option[aria-selected=false]"]
view B role/state signature: ["nav","h2","listbox[aria-activedescendant=*-option-1,aria-labelledby=*-heading]","option[aria-selected=false]","option[aria-selected=false]","option[aria-disabled=true,aria-selected=false]","option[aria-selected=false]"]
element names: ["div","button","ul","li","li","li","li"] / ["nav","h2","ul","li","li","li","li"]
The trace table shows the entire behavior in ten rows. On the third arrow key, the active
index jumps from 1 to 3: the disabled item is skipped. The Home key goes to the start;
from there, the up arrow again skips the disabled item and wraps to the end. Enter makes
the selection and closes the list; the arrow key that follows opens the list with the
selected item active. In the last row, Escape closes the list but does not break the
selection.
The two views’ role signatures are element-for-element identical: four options, the same selection states, the same disabled announcement, the same active item. The difference is only in the enclosing structure — one is a dropdown attached to a button, the other is a side panel bound to a heading. The element names differ too. The behavior stays the same while the view changes independently.
Testing Behavior Without a View
The most concrete gain of the headless approach shows up here: behavior is tested without a single view being built.
// headless.test.mjs — testing the behavior; no view, no DOM is set up import { test } from "node:test"; import { strictEqual, deepStrictEqual } from "node:assert"; import { OPTIONS, initialState, reduce, aria } from "./headless.mjs"; const play = (events, state = initialState()) => events.reduce((s, e) => reduce(s, e), state); const key = (k) => ({ type: "key", key: k }); test("arrow key skips a disabled option", () => { const d = play([key("ArrowDown"), key("ArrowDown"), key("ArrowDown")]); strictEqual(OPTIONS[d.active].value, "summit"); }); test("wraps to the start after the last option, and to the end when up is pressed at the start", () => { const atEnd = play([key("End")], { ...initialState(), open: true }); strictEqual(atEnd.active, 3); strictEqual(play([key("ArrowDown")], atEnd).active, 0); strictEqual(play([key("ArrowUp")], { ...atEnd, active: 0 }).active, 3); }); test("Enter selects and closes, Escape does not change the selection", () => { const selected = play([key("ArrowDown"), key("ArrowDown"), key("Enter")]); strictEqual(selected.selected, "middle"); strictEqual(selected.open, false); const escaped = play([key("ArrowDown"), key("Escape")], selected); strictEqual(escaped.selected, "middle"); strictEqual(escaped.open, false); }); test("the selected option becomes active on open", () => { strictEqual(play([{ type: "open" }], initialState("summit")).active, 3); strictEqual(play([{ type: "open" }], initialState(null)).active, 0); }); test("the letter buffer accumulates within 500 ms, then resets", () => { const a = play([{ type: "letter", letter: "m", time: 1000 }]); strictEqual(OPTIONS[a.active].value, "middle"); const b = reduce(a, { type: "letter", letter: "i", time: 1200 }); // "mi" -> Middle Terrace strictEqual(b.buffer, "mi"); const c = reduce(b, { type: "letter", letter: "s", time: 2000 }); // buffer reset strictEqual(c.buffer, "s"); strictEqual(OPTIONS[c.active].value, "summit"); }); test("a disabled option cannot be marked or selected", () => { const opened = play([{ type: "open" }]); strictEqual(reduce(opened, { type: "mark", index: 2 }).active, opened.active); const forced = { ...opened, active: 2 }; strictEqual(reduce(forced, key("Enter")).selected, null); }); test("the reducer does not mutate the given state", () => { const before = play([{ type: "open" }]); const copy = structuredClone(before); reduce(before, key("ArrowDown")); deepStrictEqual(before, copy); }); test("the active descendant is announced only while the list is open", () => { const closed = initialState("middle"); strictEqual(aria(closed, "k").list["aria-activedescendant"], ""); strictEqual(aria(closed, "k").trigger["aria-expanded"], "false"); const opened = reduce(closed, { type: "open" }); strictEqual(aria(opened, "k").list["aria-activedescendant"], "k-option-1"); strictEqual(aria(opened, "k").option(1)["aria-selected"], "true"); strictEqual(aria(opened, "k").option(2)["aria-disabled"], "true"); });
The file is run with node --test headless.test.mjs. In the output below, the duration
values vary by machine; the test results do not.
✔ arrow key skips a disabled option (0.42275ms) ✔ wraps to the start after the last option, and to the end when up is pressed at the start (0.060709ms) ✔ Enter selects and closes, Escape does not change the selection (0.061709ms) ✔ the selected option becomes active on open (0.342541ms) ✔ the letter buffer accumulates within 500 ms, then resets (0.077958ms) ✔ a disabled option cannot be marked or selected (0.048459ms) ✔ the reducer does not mutate the given state (0.315ms) ✔ the active descendant is announced only while the list is open (0.100875ms) ℹ tests 8 ℹ suites 0 ℹ pass 8 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 37.723209
Eight tests run with no browser and no fake DOM at all. The typeahead test supplies time as a plain number; because no real timer is waited on, the test runs in microseconds instead of milliseconds. The seventh test checks the immutability of state: if the reducer mutated the given object, the framework’s change-detection mechanism would break too.
The Limits of the Headless Approach
The cost of the headless approach is more work for the side that builds the view. Attaching attributes to markup, binding events to the right element, and scrolling the active item into view all become the caller’s responsibility. This work is repeated on every use, and it gets done incompletely somewhere.
The line is this: writing headless pays off when the behavior will be used with more than one view; if it will only be used with a single view, keeping that same behavior inside one component means less surface. A middle path is to put a default view on top of the headless core: the ready-made component is used in the common case, and the core is dropped down to when needed.
The second limit is that some behaviors cannot stay pure. Scrolling the active item into view, positioning the dropdown against the screen edge, and returning focus to the trigger when the list closes all require measurement and DOM access. These stay outside the state machine, in a separate and explicitly marked layer; the machine’s purity is preserved.
Summary
- A headless component carries behavior and an accessibility contract and produces no view; it gives back a state to read and attributes to attach to markup.
- The carrier of the behavior is a pure reducer; even time is supplied inside the event, so the test stays independent of time.
- Accessibility attributes are a derivative of state; the active item does not take focus,
its identity is announced through
aria-activedescendant. - When two different views are produced from the same state, the role and state signatures stay the same while the element names and enclosing structure change.
- Behavior is tested with event sequences without building a single view; the reducer not mutating state is itself a property worth testing.
- Behaviors that require measurement and focus movement stay outside the machine, in a separate layer; the headless approach does not pay off for single-view use.
Next Step
Everything built in this topic rested on the component’s outer surface: which props it takes, which values it gives back, which hole it leaves open. That surface itself has not been designed yet. What happens when the caller gives the component an attribute it does not recognize? When the component’s own class name collides with the one the caller provides, which one wins? Can the component both listen to an event itself and call the caller’s listener? Who owns the value — the component or the caller? The next lesson gathers the answers to these questions under prop spreading, default merging, and ownership of control.
To keep your progress and take notes, Log in
My notes
Log in to take notes.