@oriui/headless
The framework-agnostic heart of the headless layer: a behavior contract plus a zero-dependency native engine, written in vanilla TypeScript. Every framework binding consumes the same contract, so a primitive behaves identically wherever it runs — and the behavior stays swappable per primitive, without touching markup.
This is the agnostic layer. It has no framework code and no CSS. For the concrete composable API, see the Vue binding pages — useDisclosure and useDialog, with the rest linked from The core toolkit below; for the standalone styling layer see the CSS guide.
The layered idea
The headless layer is split along a framework axis. The behavior lives once, in the core; each binding is a thin adapter from the core contract to a framework's reactivity.
| Package | What it is | Framework |
|---|---|---|
@oriui/headless | Behavior contract + native engine | agnostic |
@oriui/headless/vue | Vue bindings — the composables (useDisclosure / useDialog / useCombobox / useMenu / useTabs / useToolbar / useColorPicker / useToast / useDismissable) + the useToken / useTheme bridges, as ComputedRefs / Refs | Vue |
The contract
Each primitive exposes a control shape: reactive open state, ARIA-correct prop bags, and
imperative handlers. A component spreads the prop bags onto its markup — it never hand-rolls focus,
keyboard, or ARIA. For the native disclosure engine the shape is DisclosureApi:
import { disclosure, createNormalizer } from '@oriui/headless'
// `connect()` lives in core but the prop normalizer is per-framework. A binding
// imports its own (Vue's `normalizeProps` from @oriui/headless/vue); here we build a
// minimal pass-through to keep the example self-contained.
const normalizeProps = createNormalizer((props) => props)
const service = disclosure.machine({ id: 'demo', defaultOpen: false })
const api = disclosure.connect(service, normalizeProps)
api.open // boolean
api.getRootProps() // { data-scope, data-part, data-state }
api.getTriggerProps() // { data-scope, data-part, id, type, aria-controls, aria-expanded, onClick, … }
api.getContentProps() // { data-scope, data-part, id, role: 'region', aria-labelledby, hidden, data-state }
api.setOpen(true)
api.toggle()
Every part bag (root, trigger, content) carries the anatomy's data-scope / data-part attrs, and
the content bag is a landmark region (role: 'region'). The prop bags are framework-neutral plain
objects. connect() is a pure projection of machine state to prop-getters; the binding supplies a
normalizeProps transform that maps the neutral props to the framework's casing (Vue keeps onClick)
and re-invokes connect() on every state change, wrapping the result in the framework's reactivity
primitive (the Vue adapter returns a ComputedRef). The same engine, the same ARIA, expressed once.
Four small building blocks back every primitive:
| Export | Role |
|---|---|
createMachine | A tiny reducer-based state container with a subscribe seam — the only place a binding taps |
createScope | Deterministic, SSR-stable element ids derived from one base id (getId('trigger')) |
createAnatomy | Names a component's parts and emits the data-scope / data-part attrs + matching selectors |
mergeProps | Type-aware left-to-right merge so a consumer can layer a handler / class / style on a bag |
The core toolkit
Beyond the disclosure engine above, @oriui/headless ships the behavior every binding composes —
state machines, pure roving-focus math, and the theme / token bridges. All framework-agnostic; the Vue
adapter consumes them without re-implementing anything.
| Export | What it is | Vue binding |
|---|---|---|
disclosure · combobox · menu | Namespaced state machines — each a { machine, connect, anatomy } (mirroring Zag's import * as disclosure): the reducer plus the pure prop-getter projection for the widget. | useDisclosure · useCombobox · useMenu |
rovingIntent · resolveRovingIndex · ownsArrowKeys · textDirection | Roving-tabindex toolkit — pure key→intent and intent→index math (roving), plus two DOM reads (roving-dom): whether a focused child owns the arrow keys, and the direction an element is laid out in. Not a machine. | useToolbar |
resolveToken · observeTheme | Token bridge — resolve an --ori-* token to its computed value from JS ('' when unresolvable or on the server) and subscribe to skin / mode changes. | useToken |
applyTheme · createThemeController · flushThemeInvalidation | Theme controller — apply + persist light / dark, and the workaround for the Chromium runtime-toggle invalidation bug. | useTheme |
Only the top row is a state machine — disclosure, combobox, and menu follow the machine + connect
projection shown above. The other three rows are deliberately not machines: the roving helpers are
pure functions a widget composes itself (the toolbar is a provide / inject roving context, not a
machine behind an adapter — see useToolbar), and the token / theme bridges read
and react to the DOM cascade rather than projecting reducer state.
Adapters
Behavior is chosen per primitive — provided once at the app root, never threaded through markup.
- Disclosure — a native, zero-dependency engine ships from
@oriui/headlessas the default. Nothing to wire; it just works. - Dialog — runs on the native
<dialog>element (showModal()), so the focus trap, focus return,::backdropandaria-modalcome from the platform (the page-scroll lock is indialog.css). It is the default, with no adapter or dependency required. See useDialog. - Combobox / Menu — no native platform primitive, so the default is the in-house
corestate machine (the WAI-ARIA listbox / menu keyboard). Swappable through the same contract. See useCombobox · useMenu.
Each defaults to a zero-dependency engine. The OriHeadless plugin (from @oriui/headless/vue) is the hedge for
swapping an engine per primitive — e.g. a custom or Zag-backed adapter for a genuinely hard widget —
without changing a component's template:
import { OriHeadless } from '@oriui/headless/vue'
// Each defaults to native; register an adapter only to override the one you want.
app.use(OriHeadless, { dialog: myDialog, combobox: myCombobox })
Frameworks
The package ships one framework binding, for Vue; the core underneath it imports no framework.
- Vue — the full set of composables: useDisclosure, useDialog, useCombobox, useMenu, useTabs, useToolbar, useColorPicker, useToast, useDismissable, plus the useToken / useTheme bridges. (useTabs, useToolbar, useColorPicker, useToast and useDismissable are compositional helpers — pure core logic + a composable — rather than the swappable adapter contract.) For an adapter of your own,
useServicebridges a core machine to Vue reactivity, and the core exports the toolbar's selection rules (resolveToolbarToggle,isToolbarTogglePressed).
See also
- The composables — useDisclosure · useDialog · useCombobox · useMenu · useTabs · useToolbar · useColorPicker · useToast · useDismissable · useToken · useTheme.
- Dialog — a styled component that consumes a headless contract (every component page shows the same pattern).
- CSS layer — the other framework-agnostic layer: standalone
.ori-*classes + tokens.