@zakkster/lite-headless
v1.0.0
Published
Zero-dependency headless UI primitives — 58 ARIA-correct factories + custom-element wrappers, with explicit state-attribute CSS contract. Zero-GC hot paths; reactive accessors via lite-signal.
Downloads
108
Maintainers
Readme
@zakkster/lite-headless
58 headless UI primitives on signal-based reactivity. Overlays (dialog, alert-dialog, popover, tooltip, hover-card, menu, combobox, command-palette, toast, drawer, tour) share one composition core; form controls and data/display primitives live alongside. Each primitive ships an optional
<lite-*>custom element. Framework-agnostic, tree-shakable, zero runtime deps, 1521 tests, MIT.
npm install @zakkster/lite-headless @zakkster/lite-signalimport { createDialog } from "@zakkster/lite-headless/dialog";
const dialog = createDialog({ modal: true, closeOnEscape: true });
dialog.attachTrigger(document.querySelector("#open-btn"));
dialog.attachContent(document.querySelector("#dialog"));
// Drive entry/exit animations from a signal:
dialog.status.subscribe((s) => {
panel.dataset.state = s; // "closed" | "opening" | "open" | "closing"
});
dialog.setOpen(true, "api");Current scope (v1.0.0)
Note: the detailed prose further down this README predates the catalog expansion and still describes the original overlay core in depth. The authoritative, up-to-date facts are here and in
CHANGELOG.md.
58 primitives, each a single subpath under @zakkster/lite-headless, each
with its own llms.txt (options, API, painted-attribute contract). Every
primitive also ships an optional <lite-{name}> custom element from
@zakkster/lite-headless/{name}/element.
- Overlays (share
_overlay/): dialog, alert-dialog, popover, tooltip, hover-card, menu, combobox, command-palette, toast, drawer, tour. - Form controls: slider, switch, rating, pin-input, tag-input, file-upload, color-picker, date-picker, stepper, inline-edit, form-field, password-input.
- Data / display: tabs, accordion, tree, pagination, carousel, calendar, kanban, sortable, timeline, descriptions, stat, meter, progress, skeleton, breadcrumb, badge, tag, avatar, card, banner, result, empty-state, separator, clipboard, notification-center.
- Layout / util: affix, anchor, back-top, split-panels, toolbar, toggle-group, steps, radio-group, picture.
Newest additions: separator, clipboard, password-input, alert-dialog,
and hover-card. hover-card is positioned by
@zakkster/lite-floating
(its autoUpdate pulls in @zakkster/lite-observe transitively); both are
optional peers, needed only for hover-card. The other overlays keep the
in-house _overlay/position positioner. alert-dialog reuses the dialog
contract verbatim (role="alertdialog", always modal, no backdrop-dismiss by
default).
The CSS contract is documented in docs/CSS_CONTRACT.md
(hand-curated taxonomy) and
docs/CSS_CONTRACT_APPENDIX.md
(per-primitive, auto-generated from source).
Signal-driven, framework-free, tree-shakable. One composition core for the five overlay primitives; slider and date picker live alongside as non-overlay form controls. No scheduler queue, no microtask deferral, no styling assumptions.
Table of contents
- Why this exists
- What you get
- The case for composition over inheritance
- Architecture in one diagram
- How an open propagates
- API reference
- The ARIA model
- Status as animation contract
- Submenu composition
- Edge cases pinned down
- Testing strategy
- What this is not
- Ecosystem
- Browser and runtime support
- Integration recipes
- FAQ
- npm scripts
Why this exists
Overlay primitives — dialog, popover, tooltip, combobox, menu — are the most-rewritten UI components in the industry. Every team writes them; almost every team writes them wrong. The ARIA contract is dense, the focus management is fiddly, the positioning math has corners, and the dismiss model has races that bite in production.
The headless model — primitive logic, consumer styling — solves the styling tax. But existing headless libraries (Radix, Headless UI) lock you into React. The framework-agnostic ones (Floating UI, Tippy) cover one slice each.
lite-headless was built under three constraints simultaneously:
- Framework-agnostic without a wrapper tax. Hooks into any state model via
@zakkster/lite-signal. React, Vue, Svelte, Solid, vanilla — all pay the same cost: zero. The signal IS the state. - Synchronous, predictable state transitions. No microtask scheduling, no debounce on open. When
setOpen(true)returns,doOpen()has already run, the surface is portaled, ARIA is set, focus has moved. Same call stack. Debuggable. - One composition core, five primitives. The
_overlay/layer (core state machine, positioner, dismiss, focus, portal, ARIA, scroll-lock) is shared. Adding a new primitive (combobox, menu) is composition, not a rewrite. The positioner is ~270 lines and handles all five.
flowchart LR
A[setOpen true called] --> B[bump _openSignal<br/>via lite-signal]
B --> C[effect re-runs synchronously<br/>no microtask, no scheduler]
C --> D[doOpen routine]
D --> E[portal content to container]
D --> F[positioner.update<br/>12 placements + flip + shift]
D --> G[focus trap<br/>+ scroll lock if modal]
D --> H[ARIA: aria-expanded,<br/>aria-controls, etc.]
E --> I[user CSS reacts to data-state]
F --> I
G --> I
H --> INo promise between A and I. No queueMicrotask. Just call stack.
What you get
Five primitives, each a single subpath under @zakkster/lite-headless:
createDialog(options)— modal/non-modal dialogs. Focus trap, scroll lock, Escape/outside-click dismiss, label/description ARIA wiring.createPopover(options)— anchored panels. 12 placements, flip, shift, arrow positioning, autoUpdate via scroll capture + resize +ResizeObserver.createTooltip(options)— hover/focus tooltips. Configurable open/close delays, no focus trap,aria-describedbyby default (toggle toaria-labelledby).createCombobox(options)— single-select listbox-style.aria-activedescendantpattern (focus stays on trigger), full keyboard model, typeahead with same-char cycling.createMenu(options)— action menus with real DOM focus, roving tabindex,onSelectcallbacks, disabled items, separators, submenu cascade viaattachSubmenu(parentItem, submenu).createSlider(options)— first form-control primitive. Single, range, or multi-thumb (value is always an array; length determines thumb count). Full keyboard model, pointer drag via document-level listeners, CSS-variable positioning, crossing constraints, orientation + inversion. Not an overlay.createDatePicker(options)— second form-control. Single date or date range; consumer renders the 42-cell calendar grid viagetDaysInView()+attachDay(el, date). Full keyboard nav across month boundaries (PageUp/Down for months, Shift+Page for years, arrows traverse days/weeks), range hover preview, min/max constraints, reactivetodayfor lite-time integration. Not an overlay — wrap increatePopoverfor the input-click pattern.
Each primitive ships an optional <lite-{name}> custom element from @zakkster/lite-headless/{name}/element, declarative wiring via data-trigger / data-content / data-item slots. The custom-element layer requires @zakkster/lite-element peer dep; the core primitives don't.
Three peer-dep options at install time:
| You install | You get |
| ------------------------------------ | -------------------------------------------------- |
| @zakkster/lite-headless only | All five primitive factory functions |
| + @zakkster/lite-signal | Signal-driven open, value, onChange (required for runtime) |
| + @zakkster/lite-element | <lite-dialog>, <lite-popover>, ... custom elements (optional) |
Zero runtime dependencies in the published package. The peer split lets framework adapters wire their own state primitive into the same overlay logic without duplicating it.
The case for composition over inheritance
A traditional headless library defines a base Overlay class with hooks for positioning, dismissal, focus, portal, and ARIA. Each primitive (Dialog, Popover, Tooltip) extends or configures it. Looks clean until you actually need composition: a combobox needs positioning + dismiss + a custom keyboard model + a value signal + aria-activedescendant. A menu needs positioning + dismiss + a different keyboard model + real focus + roving tabindex + submenu coordination. The base class either grows to cover every case (becomes a god object) or doesn't and you re-implement per primitive.
lite-headless inverts it. Six small modules under _overlay/ each do one thing, and each primitive imports only what it needs:
| Layer | Lines | Used by | Concern |
| ------------------- | ----: | ------------------------------------------------ | --------------------------------------------- |
| _overlay/core | ~190 | all five | open signal, status machine, cleanup graph |
| _overlay/position | ~270 | popover, tooltip, combobox, menu | 12-placement positioner, flip, shift, autoUpdate |
| _overlay/dismiss | ~110 | dialog, popover, combobox, menu | Escape stack + outside-click composedPath |
| _overlay/focus | ~135 | dialog (trap), all (return-on-close) | focus trap + previously-focused-element restore |
| _overlay/portal | ~45 | all five | move-to-container + DOM-position restore |
| _overlay/aria | ~85 | all five | id generation + IDREF-list token addition |
| _overlay/scroll-lock | ~35 | dialog only | body scroll lock + position-fixed compensation |
The total cost of a new primitive is the wiring code (~300–400 LOC) plus its own keyboard model and item registry. Combobox is 420 lines. Menu is 480. Neither duplicates positioning, dismissal, portaling, or ARIA helpers.
The composition style also means each layer is independently testable. The positioner ships with 28 tests of its own; the dismiss layer with 12; the focus trap with 9.
Architecture in one diagram
flowchart TB
subgraph Overlay["_overlay/ (shared composition layers)"]
Core[core: open signal, status machine, cleanup]
Pos[position: 12 placements, flip, shift, autoUpdate]
Dis[dismiss: Escape stack, outside-click composedPath]
Foc[focus: trap, return-on-close]
Por[portal: container relocation]
Ari[aria: ids + IDREF-list helpers]
Scr[scroll-lock: body lock, fixed-pos comp]
end
subgraph Primitives["Overlay primitives (compose _overlay)"]
D[createDialog]
P[createPopover]
T[createTooltip]
C[createCombobox]
M[createMenu]
end
subgraph FormControls["Form-control primitives (non-overlay)"]
SL[createSlider]
DP[createDatePicker]
end
Core --> D & P & T & C & M
Pos --> P & T & C & M
Dis --> D & P & C & M
Foc --> D
Por --> D & P & T & C & M
Ari --> D & P & T & C & M & SL & DP
Scr --> D
subgraph Consumer["Consumer-driven (your responsibility)"]
CSS[CSS reacting to data-state, --lh-thumb-pct, data-selected, etc.]
Trans[Transitions and animations]
Markup[Markup: trigger, content, items, thumbs, day cells]
end
Primitives --> Consumer
FormControls --> ConsumerOverlay primitives share the full _overlay/ layer. Form-control primitives reuse only the ARIA id helpers (token-safe IDREF lists, unique-id generation); they don't portal, position, dismiss, or trap focus because none of that is part of their job. Every primitive — overlay or form-control — returns a handle with attach*(el) methods and destroy(); overlays additionally expose open, status, setOpen, toggle. Form controls expose a primitive-specific surface (slider: value(), setValue(next)).
The reactive contract is one-direction: signals → DOM. Consumer mutations to the DOM (e.g. editing item labels) do not propagate back into signals; that's the consumer's responsibility. This keeps the engine small and predictable.
How an open propagates
sequenceDiagram
participant U as User code
participant C as core.setOpen
participant S as _openSignal (lite-signal)
participant E as effect (open watcher)
participant DO as doOpen
participant POS as positioner
participant FOC as focus trap
participant POR as portal
participant DOM as DOM
U->>C: setOpen(true, "api")
C->>C: same value? return.
C->>C: status -> "opening"
C->>S: _openSignal.set(true)
S->>E: effect re-runs (synchronous)
E->>DO: doOpen()
DO->>POR: portal(content, container)
DO->>POS: createPositioner(...)
POS->>DOM: write transform: translate3d(x,y,0)
DO->>FOC: trap focus, save previous active element
DO->>DOM: set data-state="open" + ARIA attrs
Note over C,DOM: All synchronous in the same call stack.<br/>setOpen returns AFTER doOpen finishes.
alt transition: true
DO->>DOM: data-status="opening"
DOM-->>DO: transitionend event
DO->>C: status -> "open"
else transition: false
DO->>C: status -> "open" immediately
endNo promise between setOpen and the DOM mutations. No queueMicrotask. No scheduler. This matters for three reasons:
- Debuggability. Stack traces show the full chain. When you set a breakpoint in your effect, you see who called it.
- Composability. The next line after
setOpen(true)can read the trigger'saria-expandedand find it's already"true". No timing puzzles in tests, noact()wrapper. - No reentrancy traps. Because everything is in one stack, the dismissal and focus-trap installation can't race against a follow-up
setOpen(false)from a Promise callback.
The cost: long synchronous chains. If you wire fifty effects to one signal, setOpen runs all fifty before returning. That's a feature in normal use (predictable cause-and-effect) and a footgun in pathological use. The engine's maxFlushPasses cap from @zakkster/lite-signal catches infinite-loop cases, but you'll feel a 50-effect chain. The recommendation: don't wire fifty effects to one signal.
API reference
createDialog(options) -> DialogHandle
const dialog = createDialog({
open?: WriteSignal<boolean>,
defaultOpen?: boolean, // default false
onOpenChange?: (next, reason) => void,
modal?: boolean, // default true
closeOnEscape?: boolean, // default true
closeOnOutsideClick?: boolean, // default true (non-modal only meaningful)
container?: HTMLElement|null, // default document.body
transition?: boolean, // default false
labelledBy?: string, // ID of a pre-existing label
describedBy?: string, // ID of a pre-existing description
});Returns: { open, status, setOpen, toggle, attachTrigger, attachContent, attachOverlay, attachClose, attachInside, attachTitle, attachDescription, destroy, destroyed }.
modal: true traps focus inside the content and applies a body scroll lock. closeOnOutsideClick on a modal is a no-op (the trap intercepts pointerdown).
createPopover(options) -> PopoverHandle
const popover = createPopover({
open, defaultOpen, onOpenChange,
placement: "bottom" | "top" | "left" | "right" |
"bottom-start" | "bottom-end" |
"top-start" | "top-end" |
"left-start" | "left-end" |
"right-start" | "right-end", // default "bottom"
offset: number, // default 8
flip: boolean, // default true
shift: boolean, // default true
boundary: "clipping" | "viewport" | HTMLElement, // default "clipping"
modal: boolean, // default false
container, transition,
labelledBy, describedBy,
});Returns: { ..., attachTrigger, attachAnchor, attachContent, attachArrow, attachClose, attachInside, destroy, _positioner }.
attachAnchor(el) separates the positioning anchor from the trigger (e.g. an icon button triggers, an input becomes the anchor). attachArrow(el) positions an arrow element relative to the resolved side. The positioner emits data-side and data-align attributes on the content so consumer CSS can rotate the arrow per side.
boundary: "clipping" (default) walks the parent chain from the anchor and uses the nearest scroll/overflow ancestor as the flip/shift boundary, intersected with the viewport. This means a popover inside a card with overflow: hidden will flip when it would exit the card, not just when it would exit the viewport. Fixed/sticky ancestors break the walk (the popover behaves as if the boundary is the viewport, which is the correct behavior for fixed descendants). For more precise control, pass boundary: someElement directly — the positioner uses that element's rect verbatim.
createTooltip(options) -> TooltipHandle
const tooltip = createTooltip({
open, defaultOpen, onOpenChange,
placement, offset, flip, shift, boundary, container,
trigger: "hover focus" | string, // space-separated subset of hover|focus|click|manual; default "hover focus"
openDelay: number, // ms; default 200
closeDelay: number, // ms; default 150
describesTrigger: boolean, // true -> aria-describedby; false -> aria-labelledby; default true
});Focus triggers ignore delays (keyboard accessibility — Tab should reveal the tooltip instantly). The closeDelay is a grace period for the pointer to cross from trigger to content without dismissing.
createCombobox(options) -> ComboboxHandle
const combo = createCombobox({
open, defaultOpen, onOpenChange,
value: WriteSignal<any>, // external controlled value
defaultValue, onValueChange,
placement: "bottom-start" /* default */,
offset, flip, shift, boundary,
typeahead: boolean, // default true
typeaheadTimeout: number, // ms; default 500
loop: boolean, // default true; ArrowDown at end wraps
autoFocus: "first" | "selected" | "none", // default "first"
closeOnSelect: boolean, // default true
closeOnEscape, closeOnOutsideClick, container, transition,
});Returns: { ..., value(), setValue(v, reason), attachTrigger, attachListbox, attachItem(el, { value, label }), attachInside, destroy }.
Uses the aria-activedescendant pattern: focus stays on the trigger button throughout, the trigger's aria-activedescendant attribute names the highlighted item's id. Keyboard model: ArrowDown/Up/Home/End move highlight, Enter selects, Escape closes, Tab closes (preserving native tab flow), printable chars typeahead (same-char cycles among matches; mixed chars build a prefix filter).
createMenu(options) -> MenuHandle
const menu = createMenu({
open, defaultOpen, onOpenChange,
placement, offset, flip, shift, boundary,
typeahead, typeaheadTimeout, loop, container, transition,
closeOnSelect: boolean, // default true
closeOnEscape, closeOnOutsideClick,
isSubmenu: boolean, // default false; see Submenu composition
submenuOpenDelay: number, // ms; default 100
submenuCloseDelay: number, // ms; default 300
safeTriangle: boolean, // default true; see Submenu composition
});Returns: { ..., attachTrigger, attachAnchor, attachContextTarget, attachMenu, attachItem(el, { onSelect, disabled, label }), attachCheckboxItem(el, { checked, onCheckedChange, label, disabled }), attachRadioItem(el, { value, group, onValueChange, label, disabled }), attachSeparator, attachSubmenu(parentItemEl, submenu), attachInside, destroy }.
Uses real DOM focus with roving tabindex (one item has tabindex="0", the rest have tabindex="-1"). Items are actions; activation calls their onSelect. Disabled items are skipped in arrow navigation. Separators get role="separator". See Submenu composition for nested menus.
attachContextTarget(el) makes right-clicking el open the menu at the pointer location — preventDefaults the native browser menu, builds a 0×0 virtual anchor at (clientX, clientY), and positions against it. Rapid re-right-click on a different spot repositions the open menu rather than double-opening.
attachCheckboxItem produces a sticky role="menuitemcheckbox" (toggling doesn't close the menu). attachRadioItem produces a role="menuitemradio" belonging to a named group — exactly one item per group is checked at a time, and activation is a one-shot pick that closes the menu (mirroring platform convention).
createSlider(options) -> SliderHandle
const slider = createSlider({
value?: WriteSignal<number[]>,
defaultValue?: number[], // length determines thumb count
onValueChange?: (next, reason) => void,
min: number, // default 0
max: number, // default 100, must be > min
step: number, // default 1, must be > 0
largeStep?: number, // default step * 10
orientation: "horizontal" | "vertical", // default "horizontal"
inverted: boolean, // default false; flips the visible axis
disabled: boolean, // default false
minStepsBetweenThumbs: number, // default 0 (touch but not cross);
// -Infinity to allow crossing
});Returns: { value(), setValue(next, reason?), min, max, step, largeStep, orientation, inverted, thumbCount, attachTrack, attachRange, attachThumb(el, index), attachLabel, destroy, destroyed }.
Value is always an array. [50] for a single-thumb slider, [20, 80] for a range, [10, 40, 70] for multi-thumb. The thumb count is locked at construction from the initial value's length; setValue enforces array length matching.
Positioning is purely CSS-variable based — the primitive writes --lh-thumb-pct on each thumb (0–100, inverted-aware) and --lh-range-start / --lh-range-end on the range fill, and leaves layout to consumer CSS. No style.left or style.transform is ever set by the primitive.
Keyboard: ArrowUp/ArrowRight always increases value, ArrowDown/ArrowLeft always decreases — regardless of orientation or inversion. This matches what assistive tech expects (the keyboard contract is about value direction, not screen direction). Shift+Arrow and PageUp/PageDown use largeStep; Home/End snap to min/max.
Pointer: pointerdown on a thumb starts a drag; pointerdown on the track moves the nearest thumb to that position AND starts a drag from there. During a drag the primitive installs document-level pointermove and pointerup listeners (not pointer capture on the thumb — which would break drag-from-track-onto-thumb). Listeners are removed on pointerup or pointercancel.
attachRange(el) is optional — only needed if your design has a visible fill between thumbs. attachLabel(el) wires aria-labelledby on every thumb to the label's id (auto-generated if not set).
createDatePicker(options) -> DatePickerHandle
const picker = createDatePicker({
mode: "single" | "range", // default "single"
value?: WriteSignal<Date[]>, // external controlled
defaultValue?: Date | Date[],
onValueChange?: (next, reason) => void, // reason: "pick"|"pick-start"|"pick-end"
minDate?: Date,
maxDate?: Date,
weekStartsOn: 0..6, // default 0 (Sunday)
disabled: boolean, // default false
now?: () => Date, // default () => new Date()
today?: Date | (() => Date), // signal-like accepted; see lite-time integration
});Returns: { value(), setValue, viewMonth, focusedDate, hoverDate, view, setView, cycleView, goToPrevMonth, goToNextMonth, goToMonth, getDaysInView(monthDate?), getMonthsInView(yearAnchor?), getYearsInView(yearAnchor?), attachGrid, attachGridContainer, attachDay(el, date), attachMonth(el, monthDate), attachYear(el, yearDate), attachPrevMonth, attachNextMonth, attachMonthLabel(el, opts?), destroy, destroyed }.
Value is always an array (matching the slider precedent): [Date | null] for single mode, [Date | null, Date | null] for range. Range values auto-sort to start ≤ end once both ends are set. All Date values are stripped to start-of-day; no time-of-day handling.
The consumer renders the calendar grid (the primitive doesn't touch markup). Call picker.getDaysInView() to get the 42 dates for the current viewMonth, then picker.attachDay(cellEl, date) per cell. attachDay is idempotent on the same element — re-attaching with a new date replaces the binding, so consumers can reuse 42 cells across month changes instead of churning DOM:
const cells = Array.from({ length: 42 }, () => {
const b = document.createElement("button");
grid.appendChild(b);
return b;
});
function repaint() {
const days = picker.getDaysInView();
for (let i = 0; i < 42; i++) {
cells[i].textContent = String(days[i].getDate());
picker.attachDay(cells[i], days[i]); // idempotent
}
}
repaint();
picker.viewMonth.subscribe(repaint);Keyboard model on the grid: ArrowLeft/Right (±1 day), ArrowUp/Down (±7 days for week navigation), PageUp/Down (±1 month), Shift+PageUp/Down (±1 year), Home/End (start/end of week respecting weekStartsOn), Enter/Space (pick the focused date). Crossing a month boundary auto-switches viewMonth — the consumer's viewMonth.subscribe(repaint) handles re-rendering.
Range hover preview: while the value is [startDate, null], hovering a day cell marks all cells between startDate and the hovered cell with data-in-range-preview. Pure visual; no value mutation until click. attachGridContainer(gridEl) installs a grid-level pointerleave to clear the preview cleanly without per-cell flicker.
States exposed on each cell: data-date="YYYY-M-D", data-selected + aria-selected, data-in-range (range mode, between endpoints), data-in-range-preview (range mode, between start and pointer), data-range-start / data-range-end (endpoints), data-today + aria-current="date", data-outside-month (padding cells from prev/next month), data-disabled + aria-disabled="true" (outside min/max), data-focused + tabindex="0" (roving — exactly one cell is tabbable).
Year + decade views (v0.7). The picker has a view signal that toggles between "days", "months", and "years". Call picker.cycleView() (typically wired to a click on the month label via attachMonthLabel(el, { clickToCycle: true })) to drill out — months view shows the 12 months of the current year in a 3×4 grid; years view shows a decade (10 years plus 1 padding year on each side, with data-outside-decade on padding). Use picker.getMonthsInView() + picker.attachMonth(el, monthDate) to render the months grid, and picker.getYearsInView() + picker.attachYear(el, yearDate) for the years grid. Both attachMonth and attachYear are idempotent like attachDay so the same 12 cells can be reused across drilldowns. Clicking a month or year cell drills back down one level; pressing Enter on a keyboard-focused month/year cell does the same. The grid's data-view attribute mirrors the signal so CSS can react. Keyboard nav adapts: in months view arrows step ±1 month (3 per row), PageUp/Down stride a year; in years view arrows step ±1 year, PageUp/Down stride a decade. Per-view label formatting too: "June 2026" / "2026" / "2020 – 2029" — override via the formatter option, which receives (viewMonth, view).
The ARIA model
Three behaviors deserve to be called out explicitly, because they're load-bearing for accessibility and they're where most overlay implementations cut corners:
Token-safe IDREF lists
aria-controls, aria-describedby, and aria-labelledby accept space-separated lists of IDs. If a consumer puts their own token in aria-describedby="my-help-text" on a trigger, then attaches a tooltip that also wants to add its id, the tooltip must add to the list, not overwrite it.
_overlay/aria.js provides addIdToken(el, attr, id) and removeIdToken(el, attr, id) that handle this. Every primitive uses them. Pre-existing tokens survive. Re-attachment doesn't duplicate ids. Detachment removes only the token it added.
Outside-click via composedPath
closeOnOutsideClick listens for pointerdown (not click), checks event.composedPath() against the registered "inside" elements, and dismisses if none match. pointerdown matters because click fires AFTER the dismiss handler would, leading to the "toggle button only opens" race — pointerdown closes the overlay, click fires, the user-handler reads the now-closed state and opens it again. composedPath matters because clicks inside a shadow tree need to be treated as inside.
The "insides" list is composed: content + trigger + anchor + any attachInside(el) external elements. For menus, the list also includes all open submenu elements and their parent items.
attachInside for external controls
An overlay's UI controls (a sidebar with open/close/toggle buttons, a settings panel that operates on the dialog) live outside the dialog content tree but should not trigger outside-click dismissal. attachInside(el) adds them to the inside list. Without it, every external control would cause the pointerdown race.
aria-activedescendant vs roving tabindex
Two ARIA patterns for "this widget has a focused item":
aria-activedescendant(combobox): focus stays on the trigger; an attribute on the trigger names the "active" item's id. Screen readers announce the active item but it never receives DOM focus. Used when the focus holder needs to retain keyboard control (typing into a search input).- Roving tabindex (menu): real DOM focus moves to items. Exactly one item has
tabindex="0"at a time; others havetabindex="-1". Tab moves focus out of the widget. Used when items are independent actions that should announce on focus.
Both are correct ARIA. Both are implemented. They're not interchangeable: getting the focus pattern wrong is the #1 accessibility bug in headless overlays.
Status as animation contract
Every primitive exposes a status signal that ticks through four states:
stateDiagram-v2
[*] --> closed
closed --> opening: setOpen(true)
opening --> open: transition:false<br/>OR transitionend
opening --> closing: setOpen(false) mid-transition
open --> closing: setOpen(false)
closing --> closed: transition:false<br/>OR transitionend
closing --> opening: setOpen(true) mid-transition
closed --> [*]: destroyThe opening and closing states exist so consumer CSS can drive entry and exit animations:
.dialog-content[data-state="opening"] { animation: fadeIn 200ms ease-out; }
.dialog-content[data-state="closing"] { animation: fadeOut 200ms ease-out; }
.dialog-content[data-state="open"] { opacity: 1; }
.dialog-content[data-state="closed"] { display: none; }transition: false (default) makes the state machine collapse: closed → opening → open runs synchronously inside one setOpen call. The intermediate states are still emitted but in the same tick.
transition: true makes the primitive wait for a transitionend event on the content element before advancing to open or closed. This means:
- The
closingstate persists until the CSS animation finishes, so the element stays in the DOM long enough to animate out. - A mid-transition
setOpen(true)whileclosingis correctly handled: status flips toopeningand the nexttransitionendadvances toopen. - If the consumer forgets to set a CSS transition on the content, the primitive will hang in
opening/closingforever waiting for an event that won't fire. The primitive has no internal timeout for this (philosophy: don't paper over consumer bugs). Solution: don't passtransition: trueif you don't have a CSS transition.
Submenu composition
Submenus are not a special primitive. They're a createMenu({ isSubmenu: true }) instance linked into a parent menu via attachSubmenu(parentItemEl, submenuInstance). This composes arbitrarily — submenu → sub-submenu → sub-sub-submenu — without baking nesting into the primitive's shape.
const file = createMenu({ placement: "bottom-start" });
file.attachTrigger(fileBtn);
file.attachMenu(fileMenuEl);
file.attachItem(newItem, { onSelect: () => doNew() });
file.attachItem(saveItem, { onSelect: () => doSave(), disabled: !hasDoc });
file.attachItem(recentItem, { label: "Recent…" }); // no onSelect: it opens a submenu
const recent = createMenu({ placement: "right-start", isSubmenu: true });
recent.attachMenu(recentMenuEl);
for (const f of recentFiles) recent.attachItem(f.li, { onSelect: () => open(f) });
file.attachSubmenu(recentItem, recent);attachSubmenu does all the wiring:
recent.attachAnchor(recentItem)— positioning anchor is the parent item.aria-haspopup="menu"onrecentItem+aria-expandedkept in sync withrecent.open().pointerenteronrecentItemopensrecentaftersubmenuOpenDelay(100ms).pointerleavefromrecentItemtriggers the safe-triangle close path (see below).pointerenteron the submenu element cancels safe-triangle and any close timer;pointerleavere-arms a delay-based close.ArrowRightonrecentItem(when focused) opensrecentand focuses its first item.ArrowLeftinsiderecentcloses onlyrecent(becauseisSubmenu: true) and leaves focus onrecentItem.
isSubmenu: true changes two behaviors in the child:
ArrowLeftcloses this menu (instead of being a no-op in a root menu).- The Escape stack pops this menu first; the parent menu's Escape handler is still installed beneath, so a second Escape closes the chain.
Only one submenu per level is open at a time. Hovering a sibling parent item closes the previous submenu immediately (no grace delay) and opens its own after submenuOpenDelay.
Safe-triangle pointer tracking
When the pointer leaves a parent item with the submenu open, the naive "300ms grace then close" approach punishes a user who's slow or who paused to read the parent item's label. Worse, it fights with a user who's already pointing diagonally toward the submenu — any pause inside the gap dismisses the menu they're trying to reach.
safeTriangle: true (default) replaces the timer-only model. On pointerleave, the primitive captures the pointer position and the submenu's near-edge corners (determined from the positioner's data-side attribute), forms a triangle from those three points, and installs a document-level pointermove listener. While the pointer stays inside the triangle, the submenu stays open. The moment the pointer exits the triangle, the submenu closes — no extra delay.
A hard cap (2 × submenuCloseDelay) ensures a still pointer inside the triangle still closes eventually, so a stationary mouse can't pin the submenu open indefinitely. Set safeTriangle: false to revert to the v0.3 delay-only behavior.
Edge cases pinned down
These are the questions you'd ask in a code review, with the answers:
pointerdownoutside the overlay also matched a panel button. That's the toggle-button race: pointerdown closes the overlay before the click handler runs.attachInside(el)exists to add external controls to the "inside" list. The dismiss handler usescomposedPath()so the check works across shadow DOM.- Focus restoration on close. Every primitive saves
document.activeElementat open time and restores it on close (except when the consumer focused something else in between — we check that the recorded element is still in the DOM and was the active one). Dialog's focus trap is additionally installed on top for the duration ofopen. - Portal severance. When
attachContentportals an element tocontainer, the original parent reference is stored. Ondestroyor when the consumer detaches the content, the element is moved back to its original position (using a comment-node placeholder to preserve sibling order). If the placeholder's parent was removed in the meantime, restoration is skipped. - Mid-transition re-open.
setOpen(true)whilestatus === "closing"correctly flips back toopening. The transitionend listener checks the current direction at fire time, not at install time. - ARIA token leaks. Detaching a primitive removes only the tokens it added to
aria-controls/aria-describedby/aria-labelledby. Pre-existing consumer tokens survive. If the consumer's token happens to equal the primitive's generated id, that's an id collision and the leak protection can't distinguish; but generated ids are namespaced (lh-dialog-,lh-popover-, etc.) to make collisions improbable. - Outside-click composedPath inside shadow DOM.
event.composedPath()returns the full chain across shadow boundaries. If the inside element lives inside a shadow root, the check still works.event.targetalone wouldn't. - Effect ordering after
setOpen. Effects fire in registration order; the open effect installs ARIA, the status effect drivesdata-state, and the position/focus/portal side effects happen insidedoOpen. There is no observable ordering issue between effects of one primitive; consumer effects subscribed toopenorstatusfire after the primitive's effects (because they were registered later, afterattach*). - Scroll lock under modal dialogs. Setting
body { overflow: hidden }causes a layout shift if a scrollbar was previously visible._overlay/scroll-lockmeasureswindow.innerWidth - document.documentElement.clientWidthbefore locking and applies that aspadding-rightto preserve layout. - Combobox value signal is external + internal. Pass
value: yourWriteSignalto control externally; omit it and the primitive owns an internal signal.value()reads from whichever is in use;setValue(v)writes to whichever is in use. There's no fork between "controlled" and "uncontrolled" — same code path. - Menu typeahead on disabled items. Typeahead matches
labelagainst the live items list and skips disabled entries. Same for arrow navigation. Disabled items are not focusable and not selectable but remain visible (and emitaria-disabled="true"). - Destroy during transition. Calling
destroy()whilestatus === "opening"synchronously closes (without waiting for transitionend) and tears down. The portal restoration happens before the cleanup graph drains.
Testing strategy
305 tests across the primitive suite + composition layers. Three discipline points:
Tier 1 — Per-primitive behavior
test/_setup.js — happy-dom bootstrap with explicit window.happyDOM.close()
on every teardown (without it, popover.test.js SIGKILLs after
16s due to accumulated detached-window state)
test/dialog.test.js — modal/non-modal, focus trap, attachInside (2)
test/popover.test.js — 12 placements, flip, shift, autoUpdate, arrow positioning
test/tooltip.test.js — hover/focus/click triggers, delay model, ARIA describes
test/combobox.test.js — aria-activedescendant, typeahead, autoFocus modes (21)
test/menu.test.js — roving tabindex, disabled-skip, submenu open/close (26)
test/menu-v04.test.js — context menu, checkbox/radio items, safe-triangle (15)
test/slider.test.js — keyboard nav, pointer drag, range crossing, CSS vars (33)
test/datepicker.test.js — single/range picking, keyboard across months, hover preview, min/max (39)
test/datepicker-views.test.js — year + decade views, drilldown flow, per-view keyboard nav (32)
test/boundary-walk.test.js — findClippingAncestor across overflow ancestors, fixed-position fallback (11)Per-test setupDOM() / teardownDOM() with happyDOM.close() is mandatory. Without it, internal task queues accumulate across tests and the suite SIGKILLs after ~16 seconds.
Tier 2 — Stability across runs
The full suite passes 305/305 three consecutive runs in ~10 seconds each. Run-to-run flakiness is the #1 sign of a state leak between tests (closures retaining DOM, timers not cleared, effects not disposed). The triple-run discipline catches it.
npm test # one run
npm run test:stable # three consecutive runs, fails if any disagreesTier 2.5 — TypeScript declaration accuracy
Since v1.0.1, the public surface is type-checked at build time. types.d.ts declares the factories, return shapes, options, and LiteXElement host interfaces for all 53 primitives; type-tests/api-surface.ts exercises that surface (factories + element wrappers via document.querySelector("lite-X")) so any drift between the declared types and what consumers actually use surfaces immediately.
npm run types # tsc --noEmit (silent on success)
npm run types:watch # rerun on saveThe pipeline caught ~40 declaration-vs-source mismatches at the v1.0.1 audit (wrong attach method names, missing methods, host-vs-primitive accessor renames). See CHANGELOG.md for the full list. Going forward, every release must tsc --noEmit clean before shipping.
Tier 3 — Real-browser harness (Playwright)
happy-dom doesn't simulate layout — getBoundingClientRect() returns zeros for unstyled elements — which means the unit suite can't directly verify safe-triangle geometry, real drag against an actual track rect, popover flip against the real viewport, or focus events crossing real DOM boundaries. Tier 3 fills that gap with 26 Playwright tests across 4 spec files.
npx playwright install chromium # one-time browser fetch (~150 MB)
npm run test:browser # full suite, headless
npm run test:browser:headed # watch it run
npm run test:browser:ui # interactive picker UIEach fixture is a standalone HTML page that imports its primitive from source, wires the markup, and exposes the handle on window.__menu etc. so specs can introspect via page.evaluate(...). The zero-dependency static server at test-browser/serve.mjs is started automatically by Playwright's webServer config. See test-browser/README.md for which paths each spec covers and the policy for when to write a browser test vs a unit test (the latter is the default; a browser test costs ~30× a unit test).
Chromium-only by default. Firefox and WebKit projects are commented in playwright.config.js and can be uncommented for cross-browser coverage.
Tier 4 — Live demo as integration test
demo/index.html runs all seven primitives in nine scenes with live wiring. It catches integration issues that unit and browser tests can't: cross-scene portal residue, ARIA token interactions when multiple primitives share a trigger area, focus restoration across scene switches. Boss/QA review happens against this file.
What this is not
- A rendering library. No virtual DOM, no JSX runtime, no template compiler. You bring the markup. The primitives attach behavior to your elements.
- A styling system. Zero CSS in the package. The primitives expose
data-state,data-status,data-side,data-align,data-disabled,data-highlighted,data-focusedattributes. You style them. - An animation engine. The
statussignal is the integration point. Pair with CSS transitions, GSAP,lite-ease, Web Animations API, Motion One, whatever. - A positioning library beyond the basics. The
_overlay/positionlayer covers 12 placements + flip + shift + arrow + autoUpdate, which handles ~95% of cases. For nested-scroll-ancestor boundaries, virtual-element anchors with custom rect, or middleware-style transforms, use Floating UI directly and integrate viaattachAnchor. - A solution for every overlay. Date pickers, color pickers, command palettes, drawers/sheets, popconfirms — these are all higher-level patterns built on these primitives. Build them on top.
Ecosystem
Built on @zakkster/lite-signal for reactivity. Optional integrations:
@zakkster/lite-signal— required peer. Synchronous, zero-GC reactive primitives. Theopen,value,statussignals come from here.@zakkster/lite-element— optional peer. Enables the<lite-dialog>/<lite-popover>/<lite-tooltip>/<lite-combobox>/<lite-menu>custom elements. Reactive observed attributes, typed reflected props.@zakkster/lite-ease— Subscribe totooltip.statusand drive a spring throughlite-easefor physics-based entry/exit. Zero allocations per frame.@zakkster/lite-virtual— Combobox listbox with 10,000 items? Wirelite-virtualto the listbox container;attachItemeach visible row, detach off-screen rows.@zakkster/lite-form— Headless reactive forms. The picker's writablevaluesignal (date picker, slider, or combobox) is the integration point: passvalue: form.field("departureDate")and the form library owns the value; the primitive just renders and emitsonValueChange. No custom adapter, no controlled/uncontrolled fork.@zakkster/lite-time— Drift-corrected reactive cadence. For long-lived date pickers (always-visible sidebar calendars), passtoday: liteTime.todaySignaltocreateDatePicker— the picker's paint effect re-subscribes to the day-rollover signal, so the today-marker stays fresh past midnight without any consumer wiring.@zakkster/lite-router— Drivedialog.openfrom a URL search param signal — dialog state becomes shareable links.@zakkster/lite-persist— Persistcombobox.valueto localStorage with debounced cross-tab sync.
Browser and runtime support
Pure ES2020 + ResizeObserver + PointerEvent + composedPath. Runs anywhere modern.
| Target | Supported | Notes |
| ------------------------------------- | --------- | -------------------------------------------------- |
| Chrome / Edge (last 2 majors) | ✓ | Reference target |
| Firefox (last 2 majors) | ✓ | |
| Safari 15+ | ✓ | composedPath polyfill not needed |
| iOS Safari 15+ | ✓ | dvh units recommended for content max-height |
| Android Chrome | ✓ | |
| Node 18+ (for tests) | ✓ | happy-dom 15+ is the test DOM |
| Bun, Deno | ? | Should work — not tested in CI |
ESM-only. No CommonJS build. Bundlers (Vite, Rollup, esbuild, webpack 5) handle this transparently. If you're still on a CommonJS-only stack, write a tiny wrapper.
Integration recipes
Spring-driven entry/exit
import { spring } from "@zakkster/lite-ease";
const dialog = createDialog({ transition: true });
const s = spring({ stiffness: 300, damping: 25 });
dialog.status.subscribe((state) => {
if (state === "opening") s.set(1);
else if (state === "closing") s.set(0);
});
s.subscribe((value) => {
contentEl.style.transform = `scale(${0.95 + 0.05 * value}) translateY(${-8 * (1 - value)}px)`;
contentEl.style.opacity = String(value);
});The CSS no-transition trick: don't set a transition rule on transform/opacity since the spring is animating them directly. The data-status attribute still ticks through opening / open / closing / closed so other parts of the UI can react.
Portal severance with CSS
The content element gets moved to container (default document.body) when open. If you have CSS rules that select on the original parent (.modal-wrapper > .modal-content { ... }), they stop matching once portaled. Two options:
- Don't portal — pass
container: null. The content stays in place. Losesposition: fixedstacking benefits. - Use BEM-style class names instead of structural selectors.
.modal-contentworks wherever the element lives.
inert polyfill for legacy browsers
Modal dialogs apply inert to siblings of the content's parent so focus and pointer events can't reach the rest of the page. Safari < 15.4 doesn't support inert. For legacy support, add the WICG inert polyfill before importing @zakkster/lite-headless.
FAQ
Why is @zakkster/lite-signal a peer dependency and not a regular dependency?
Because a typical app has one signal library in use across many packages, and double-installing breaks identity comparisons (isSignal(s) returns false across registries). The peer dep forces the consumer to pick one instance.
Combobox or menu — what's the difference?
Combobox is for selecting a value from a list. It has a value signal, aria-selected on items, an optional typeahead filter, and the trigger displays the selected label. Menu is for invoking actions. Items have onSelect callbacks, no selected state, and real DOM focus moves to items so screen readers announce them. If your dropdown's items are actions, use menu. If they're selections, use combobox.
How do I tune submenu hover-grace?
safeTriangle: true (default) replaces a fixed-delay close with pointer-position tracking — the submenu stays open as long as the pointer is moving diagonally toward it, and closes the moment it leaves that triangular region. For a more permissive feel, raise submenuCloseDelay (it's the hard cap, ×2, for a still pointer inside the triangle); for a snappier feel, lower it. Pass safeTriangle: false to fall back to v0.3's delay-only behavior. The submenuOpenDelay (default 100ms) is independent and governs hover-to-open latency.
Does it support RTL?
The positioner is direction-agnostic — left and right are physical, not logical. For RTL layouts, swap your placement choices (use right-start where you'd use left-start in LTR). Future work: a direction: 'ltr' | 'rtl' option that swaps automatically. Not in v0.3.
Can I have a tooltip on a tooltip? Yes, but you probably shouldn't. The escape stack handles arbitrary nesting; the focus model doesn't care about depth. UX-wise, nested tooltips usually mean you have one tooltip too many.
How does attachInside differ from extending composedPath insides?
composedPath already covers shadow-DOM-internal elements. attachInside is for external elements that aren't in the content's DOM tree at all (a sidebar control, a toolbar button). The dismiss handler checks both.
My menu opens off-screen on mobile.
boundary: "clipping" (default) walks the nearest scroll/overflow ancestor to find the containing rect. The menu flips and shifts within that boundary, intersected with the viewport. If your menu is taller than its boundary, you need to size it: max-height: 80dvh; overflow-y: auto. The positioner doesn't size content; that's a consumer concern. If you need the menu bounded by a specific element (e.g. a card), pass boundary: cardEl directly instead of relying on the auto-walk.
Tree-shaking — do I pay for primitives I don't use?
No. Each primitive is a separate subpath (/dialog, /popover, /tooltip, /combobox, /menu). Import only what you use; modern bundlers (Vite, Rollup, esbuild) handle the rest. The _overlay layer is shared across the primitives you do import.
npm scripts
npm test # 305-test suite, ~5s
npm run test:stable # three consecutive runs; fails if any disagreesLicense
MIT © Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal·lite-element·lite-ease·lite-virtual·lite-router·lite-persist
