Guides
The Headless Layer
Behaviour without a view — the rules a listbox or a dialog follows, shared by every target and published for your own components.
Two components can paint nothing alike and still think identically. A select and a combobox look nothing like each other, but an arrow key moves the active option and wraps at the ends in both. A dialog in React and the same dialog in Svelte share no code, yet both have to hold focus inside themselves and hand it back on close.
Those rules are not visual, and they are the part that is easy to get subtly
wrong. So they do not live in the components. They live in src/headless/ — one
module per behaviour, read by every target that needs it.
What makes a module headless here
Section titled “What makes a module headless here”Three properties, and they are the point rather than a style preference:
- No DOM. A module decides which option is active or where focus should
land. Collecting elements and calling
.focus()stays with whoever is drawing. - No framework. Plain functions over plain data, so the same file compiles into a Lit element, a React component and a Svelte component unchanged.
- No styling. Nothing in here knows a class name exists.
The payoff is testability. A rule that touches no DOM runs in milliseconds without a browser, so the awkward cases — an empty dialog, a single focusable element, focus sitting somewhere the component does not know about — get covered properly instead of being hoped about.
The modules
Section titled “The modules”| Module | Decides | Read by |
|---|---|---|
field | Which message aria-describedby points at, and in what order | Input, Select, Combobox, Meter input |
pagination | Which page numbers show and where the gaps fall | Pagination |
bulk-progress | Percentage, tone, and the sentence read aloud | Bulk progress |
meter-input | Whether a reading is plausible against the last one | Meter input |
money | Currency, precision, compact scale, and whether a negative is a debt or a credit | Money, unit map, approval card |
status | The shared status sets: each value’s words, tone and icon | Status, unit map, approval card |
unit-map | Floors from units, occupancy, tenants and avatar stacks, search, moving between units | Unit map |
timeline | Grouping by day, step states, the reading order of a tracking timeline | Timeline |
zoom | Zooming about a point, panning within the frame, the zoom keys | Evidence compare, file viewer |
approval | Which decisions need a reason, and whether one can be sent | Approval card |
file | A file’s kind and extension, whether a browser can preview it, stepping through a set | File, file viewer |
checklist | How a set of checks comes out, and the sentence that says so | Checklist |
listbox | Active option, wrapping, filtering, the empty-panel wording | Select, Combobox |
dialog | Escape dismissal, the focus trap, where focus starts | Dialog |
menu | Opening direction, roving focus, Home and End, typeahead | Menu, menu button, menubar, editor |
tabs | Orientation, activation mode, stepping and Home/End | Tabs |
tooltip | Open and close delays, what each intent means, Escape | Tooltip |
table | Search, filter, the sort cycle and comparator, paging | Table, data table |
checkbox | Tri-state, the group summary, what aria-checked reports | Checkbox |
selection | Single and multiple choice within a set | Radio group, toggle group |
slider | Snapping, clamping, value from a pointer, which thumb moves | Slider |
position | Where a floating box sits, and how it flips and shifts to stay in view | Popup, Tooltip, Dropdown |
dismiss | Which overlay Escape or an outside press closes when several are open | Dialog, Popover, Menu |
hover-card | Hover intent, and the grace period for crossing to the card | Hover card |
collapsible | Which panels are open, single or several, and whether the last may close | Accordion, Collapsible |
stepper | Step status, what may be reached, and progress | Stepper |
breadcrumb | Which crumbs survive when the trail is too long | Breadcrumb |
calendar | Month grids, bounds, and range selection | Calendar, Date picker |
number-input | Parsing, clamping, stepping without floating-point drift | Number input |
otp | Distributing characters across slots, pasting, focus | OTP input |
toast | The queue, its limit, and when a toast expires | Toast |
presence | Staying mounted long enough to animate out | Any dismissible surface |
tree | Flattening for navigation, expanding, the arrow keys | Tree view |
carousel | Index, looping, and how far to shift | Carousel |
virtual | Which rows are on screen, and the spacer around them | Virtual list |
resizable | Sizes from a drag, honouring each panel’s limits | Split panel, image comparer |
Five of these are published ahead of anything that reads them — dismiss,
presence, selection, toast and virtual. The rules and their tests are
there, and the component each one names is the one still to be built, so the
“Used by” column reads as intent for those rows rather than fact.
menu reads listbox for the wrap itself rather than writing it a second time,
so installing a dropdown pulls both. A menu still differs in the part that
matters: a listbox marks its active option with aria-activedescendant and
leaves focus on the field, while a menu moves real focus onto the item.
Each has its own page: listbox rules covers the panel and keyboard model in full.
Reading them directly
Section titled “Reading them directly”They ship as ordinary registry items, so a component that reads one pulls it in automatically:
npx @nana-tec/ui add dialoginstalls headless/dialog.ts alongside dialog.tsx. You can also install a
module on its own and build your own view on it:
npx @nana-tec/ui add headless-listboxOr import from the published package without copying anything:
import { keyToAction, project, reduce } from "@nana-tec/ui-headless/listbox";import { trapTab, isDismissKey } from "@nana-tec/ui-headless/dialog";One package, copied on the way out
Section titled “One package, copied on the way out”The rules are a package — @nana-tec/ui-headless — written once. Every target
imports it, so there is no second copy to keep in step:
import { trapTab, isDismissKey } from "@nana-tec/ui-headless/dialog";Installing a component still gives you the file. The registry resolves that
import to the module it ships, so add dialog writes headless/dialog.ts into
your project and rewrites the import to point at it. One source here, source you
own there.
You can also take the package as an ordinary dependency and build your own views on it:
npm install @nana-tec/ui-headlessModules are exported one by one — @nana-tec/ui-headless/listbox — because
several answer the same question for different controls. A dialog and a tooltip
both have an isDismissKey and they are not the same function, so the barrel
namespaces them rather than flattening them into one collision.
Beneath it: the primitives
Section titled “Beneath it: the primitives”@nana-tec/ui-primitives holds what every rule set needs and none of them owns:
merging props so two handlers both run, joining ids for aria-describedby,
writing data-state the way a CSS selector expects, resolving a controlled value
against an internal one.
It knows nothing about any component, and nothing about React, Svelte or the DOM
— so headless/field joins its ids with joinIds rather than writing the same
filter-and-join a fourth time. Installing a component that needs one pulls it in:
input asks for headless-field, which asks for primitives-ids.
Where it does not apply
Section titled “Where it does not apply”nana-dialog does not read the dialog module. It renders a native <dialog>,
and the browser already supplies trapping and Escape for it. Sharing a rule that
the platform provides for free would be worse than duplication: it would mean
running our version of something the browser does better.
The rule is that behaviour goes in the layer when more than one target has to implement it by hand — not whenever more than one component happens to have it.