Skip to content
Nana UI

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.

View .md Download

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.

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.

ModuleDecidesRead by
fieldWhich message aria-describedby points at, and in what orderInput, Select, Combobox, Meter input
paginationWhich page numbers show and where the gaps fallPagination
bulk-progressPercentage, tone, and the sentence read aloudBulk progress
meter-inputWhether a reading is plausible against the last oneMeter input
moneyCurrency, precision, compact scale, and whether a negative is a debt or a creditMoney, unit map, approval card
statusThe shared status sets: each value’s words, tone and iconStatus, unit map, approval card
unit-mapFloors from units, occupancy, tenants and avatar stacks, search, moving between unitsUnit map
timelineGrouping by day, step states, the reading order of a tracking timelineTimeline
zoomZooming about a point, panning within the frame, the zoom keysEvidence compare, file viewer
approvalWhich decisions need a reason, and whether one can be sentApproval card
fileA file’s kind and extension, whether a browser can preview it, stepping through a setFile, file viewer
checklistHow a set of checks comes out, and the sentence that says soChecklist
listboxActive option, wrapping, filtering, the empty-panel wordingSelect, Combobox
dialogEscape dismissal, the focus trap, where focus startsDialog
menuOpening direction, roving focus, Home and End, typeaheadMenu, menu button, menubar, editor
tabsOrientation, activation mode, stepping and Home/EndTabs
tooltipOpen and close delays, what each intent means, EscapeTooltip
tableSearch, filter, the sort cycle and comparator, pagingTable, data table
checkboxTri-state, the group summary, what aria-checked reportsCheckbox
selectionSingle and multiple choice within a setRadio group, toggle group
sliderSnapping, clamping, value from a pointer, which thumb movesSlider
positionWhere a floating box sits, and how it flips and shifts to stay in viewPopup, Tooltip, Dropdown
dismissWhich overlay Escape or an outside press closes when several are openDialog, Popover, Menu
hover-cardHover intent, and the grace period for crossing to the cardHover card
collapsibleWhich panels are open, single or several, and whether the last may closeAccordion, Collapsible
stepperStep status, what may be reached, and progressStepper
breadcrumbWhich crumbs survive when the trail is too longBreadcrumb
calendarMonth grids, bounds, and range selectionCalendar, Date picker
number-inputParsing, clamping, stepping without floating-point driftNumber input
otpDistributing characters across slots, pasting, focusOTP input
toastThe queue, its limit, and when a toast expiresToast
presenceStaying mounted long enough to animate outAny dismissible surface
treeFlattening for navigation, expanding, the arrow keysTree view
carouselIndex, looping, and how far to shiftCarousel
virtualWhich rows are on screen, and the spacer around themVirtual list
resizableSizes from a drag, honouring each panel’s limitsSplit 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.

They ship as ordinary registry items, so a component that reads one pulls it in automatically:

npx @nana-tec/ui add dialog

installs 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-listbox

Or 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";

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-headless

Modules 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.

@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.

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.