oriUI

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

PackageWhat it isFramework
@oriui/headlessBehavior contract + native engineagnostic
@oriui/headless/vueVue bindings — the composables (useDisclosure / useDialog / useCombobox / useMenu / useTabs / useToolbar / useColorPicker / useToast / useDismissable) + the useToken / useTheme bridges, as ComputedRefs / RefsVue

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:

ts
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:

ExportRole
createMachineA tiny reducer-based state container with a subscribe seam — the only place a binding taps
createScopeDeterministic, SSR-stable element ids derived from one base id (getId('trigger'))
createAnatomyNames a component's parts and emits the data-scope / data-part attrs + matching selectors
mergePropsType-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.

ExportWhat it isVue binding
disclosure · combobox · menuNamespaced 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 · textDirectionRoving-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 · observeThemeToken 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 · flushThemeInvalidationTheme 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/headless as the default. Nothing to wire; it just works.
  • Dialog — runs on the native <dialog> element (showModal()), so the focus trap, focus return, ::backdrop and aria-modal come from the platform (the page-scroll lock is in dialog.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 core state 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:

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

See also