Primitives
@aihu/primitives is a set of headless behavior primitives — WAI-ARIA APG patterns implemented as vanilla custom elements. Each emits DOM structure, ARIA wiring and data-state attributes, and owns its state on @aihu/signals.
Every primitive ships zero CSS. You style every part yourself, typically with the css-engine's utilities and cn().
Why headless
A headless primitive gives you the hard part — focus management, keyboard interaction, ARIA roles and relationships, open/close state — without imposing a look. Each piece reflects data-state="open" | "closed" and friends, so your CSS selectors drive appearance while the primitive guarantees the accessibility contract.
This mirrors the Radix/Ark root-and-pieces model: a root owns the state and provides it through a DOM-walk context; pieces inject it by walking up the real DOM, across shadow boundaries, nearest-provider-wins.
The behaviors
Overlays
Dialog — the APG Modal Dialog pattern. Pieces: <aihu-dialog-root>, -trigger, -content, -backdrop, -close, -title, -description. Focus trap with return-focus, Escape to close, outside-click to close when modal, and the full role="dialog" / aria-modal / aria-labelledby / aria-describedby set plus the trigger's aria-haspopup / aria-expanded / aria-controls. defineDialog().
Popover — a non-modal disclosure. The trigger follows APG Disclosure wiring (aria-expanded + aria-controls); the panel takes the dialog role without aria-modal, and focus is not trapped. That difference is the whole point: use Popover when the page behind stays live, Dialog when it must not. definePopover(prefix = 'aihu') — note it takes a tag prefix, not a tag.
Tooltip — the APG Tooltip pattern. The trigger is aria-describedby the content (not labelled-by), the content is role="tooltip" and not focusable, Escape dismisses. Configurable open-delay / close-delay (700 / 300 ms). Placement reuses the css-engine's position() shim, so there is no floating-ui dependency. defineTooltip().
Form controls
Checkbox — APG Checkbox, including the tri-state indeterminate case. <aihu-checkbox-root> plus an <aihu-checkbox-indicator> styling hook. defineCheckbox().
Switch — APG Switch. Deliberately a sibling of Checkbox rather than shared code: the ARIA contracts genuinely diverge (binary vs tri-state, and Enter behaves differently). <aihu-switch-root> + <aihu-switch-thumb>. defineSwitch().
Radio group — APG Radio Group, built by extending <aihu-roving-focus>: the root is the roving-tabindex container. Pieces: root, -item (role="radio"), -indicator. defineRadioGroup().
Slider — APG Slider, single-thumb. defineSlider().
Input / Textarea — text controls over a shared base, wired into the form-control context below. defineInput(tag = 'aihu-input'), defineTextarea(tag = 'aihu-textarea').
Label — label association that survives shadow boundaries, where a native <label for> would not. defineLabel(tag = 'aihu-label').
Button — AihuButton, the APG Button pattern as a base class. On a non-native host it sets role="button" + tabindex="0" and handles Enter/Space to fire a synthetic click; a native <button> defers to native semantics. Reflects aria-pressed, aria-disabled, data-state, and inherits disabled from an ancestor form-control. It is a base class, not a pre-registered tag — extend it, or register a concrete element with defineButton(tag).
Separator — role="separator" with orientation. defineSeparator(tag = 'aihu-separator').
Substrates
The lower-level pieces the behaviors compose on. Use them directly when building your own:
| Primitive | Subpath | Role |
|---|---|---|
| DOM context | @aihu/primitives/context |
Live ancestor-traversal context. The root-to-piece coordination mechanism. Self-contained — does not import @aihu/context. |
| Focus trap | @aihu/primitives/focus-trap |
Tab-cycle containment with return-focus. Its own subpath, so you can trap focus without a dialog. |
| Presence gate | @aihu/primitives/presence-gate |
Holds children mounted through an exit transition. |
| Roving focus | @aihu/primitives/roving-focus |
Roving-tabindex management for composite widgets, configurable orientation. |
| Collection | @aihu/primitives/collection |
Ordered registration of descendant items. |
| Config provider | @aihu/primitives/config-provider |
Propagates colorScheme / density / direction to descendants. |
| Form control | @aihu/primitives/form-control |
Shared label / description / validity wiring, plus the disabled context descendants inherit. |
Styling them
Primitives are unstyled, so you bring the CSS. Style each data-state with utilities, and merge caller overrides with cn():
import { defineDialog } from '@aihu/primitives/dialog'
import { cn } from '@aihu/css-engine/runtime/cn'
defineDialog()
const contentClass = cn('rounded-lg p-6 bg-surface shadow-lg', userClassName)<aihu-dialog-root>
<aihu-dialog-trigger>Open</aihu-dialog-trigger>
<aihu-dialog-backdrop class="fixed inset-0 bg-black/40 data-[state=closed]:opacity-0"></aihu-dialog-backdrop>
<aihu-dialog-content class="rounded-lg p-6 bg-surface shadow-lg">
<aihu-dialog-title>Title</aihu-dialog-title>
<aihu-dialog-description>Body copy.</aihu-dialog-description>
<aihu-dialog-close>Close</aihu-dialog-close>
</aihu-dialog-content>
</aihu-dialog-root>The primitive guarantees the focus trap, Escape, ARIA and data-state; your utilities supply the look.
When not to hand-roll
@aihu/ui is a registry of styled components built on these primitives — you copy a component into your project and own the source, rather than depending on a black box. If you want a styled dialog, switch or slider rather than a headless one, start there and reach for primitives when you need a behavior the registry does not cover.
See also
- Styling — scoped output, variants,
cn() - Theming — the tokens these components consume
- @aihu/primitives — the export tables