Zag.js Framework Adapter Anatomy
What a Zag.js framework adapter must implement, derived from reading every source file in the six official adapters (react, solid, svelte, vue, preact, vanilla).
Zag.js splits every UI machine into two halves: a framework-agnostic state machine (packages/core) and a thin per-framework binding layer (packages/frameworks/*) that turns that machine into idiomatic, reactive framework code. This document is a reverse-engineered reference for what that binding layer — the "adapter" — must implement, built entirely from reading the adapter source trees, never from prior assumptions.
Source snapshot: the clone at data/zag is on branch main, 5 commits past the @zag-js/accordion@1.43.0 tag (git describe --tags → 1.43.0-5-geabc04440). All six framework packages report "version": "1.43.0" in their package.json. A v2 branch exists on the remote (visible via git branch -a, tags up to @zag-js/vue@2.0.0-next.1) but is not checked out in this tree — every claim below is about the v1.43 line. No 2.0-next code paths were read or cited.
Pages in this reference
- Overview & the adapter contract (this page)
- The five responsibilities — bindable, reactivity bridge, lifecycle, normalizeProps, refs/track/watch, compared across all six frameworks
- The vanilla adapter deep-dive
- Divergence map — contract vs. framework idiom
- Checklist for a new adapter
1. The adapter contract
Every framework package under packages/frameworks/*/src exports the same minimal surface. Confirmed from each package's index.ts:
| Export | react | solid | svelte | vue | preact | vanilla |
|---|---|---|---|---|---|---|
useMachine (or VanillaMachine class) | yes | yes | yes | yes | yes | VanillaMachine |
normalizeProps | yes | yes | yes | yes | yes | yes |
mergeProps | re-export of @zag-js/core | own impl (accessor-aware) | own impl (class handling + style) | re-export of core | re-export of core | own impl (style stringify) |
useSyncExternalStore | re-export from react | own impl | own impl (.svelte.ts) | own impl | own impl (wraps preact/compat) | n/a (uses subscribe/publish instead) |
Portal / portal | yes (portal.tsx) | no | yes (Svelte action, portal.ts) | no (not present in src/) | declared in index.ts but no portal.tsx file exists in src/ | no |
reflect | no | no | yes (reflect.ts, Proxy that rebinds methods each access) | no | no | no |
spreadProps | no (JSX handles spreading) | no | no | no | no | yes (spread-props.ts, imperative DOM diffing) |
Note: preact's packages/frameworks/preact/src/index.ts re-exports "./portal" but no portal.tsx file exists under packages/frameworks/preact/src in this tree — likely stale/broken export as of this commit; flagged here rather than silently ignored.
The Service<T> protocol
Every adapter's useMachine must return an object satisfying Service<T extends MachineSchema>, defined in packages/core/src/types.ts:
export type Service<T extends MachineSchema> = {
getStatus: () => MachineStatus
state: State<T> & {
matches: (...values: T["state"][]) => boolean
hasTag: (tag: T["tag"]) => boolean
}
context: BindableContext<T>
send: (event: EventType<T["event"]>) => void
prop: PropFn<T>
scope: Scope
computed: ComputedFn<T>
refs: BindableRefs<T>
event: EventType<T["event"]> & {
current: () => EventType<T["event"]>
previous: () => EventType<T["event"]>
}
}Field by field, with the core type each must satisfy:
| Field | Type contract (core/src/types.ts) | Purpose |
|---|---|---|
state | Bindable<T["state"]> & { matches, hasTag } | Current machine state as a bindable cell, plus state-value helpers |
send | (event: EventType<T["event"]>) => void | Dispatch an event into the machine |
context | BindableContext<T> — get/set/initial/hash per key | Controlled/uncontrolled context values (bindables) |
prop | PropFn<T> — <K>(key: K) => T["props"][K] | Read the live, framework-reactive props object by key |
scope | Scope — getRootNode/getDoc/getWin/getById/getActiveElement/isActiveElement | DOM environment abstraction (see packages/core/src/scope.ts) |
refs | BindableRefs<T> — get/set by key | Mutable, non-reactive machine-owned refs (e.g. timers, DOM handles) |
computed | ComputedFn<T> — <K>(key: K) => T["computed"][K] | Derived values recomputed on each access from machine.computed |
event | EventType<T["event"]> & { current, previous } | Last dispatched event plus current/previous accessors |
getStatus | () => MachineStatus (NotStarted | Started | Stopped, an enum in core/src/types.ts) | Lifecycle status query, used to guard send before start / after stop |
Flush semantics. The Machine<T>.context factory receives a flush: (fn: VoidFunction) => void callback (ContextParams<T> in core/src/types.ts). It is the adapter's hook for forcing a synchronous re-render after a context mutation that must be visible immediately (e.g. before a subsequent DOM read). Each adapter implements it differently — see the divergence map — but all six must supply some implementation, even if it's a same-tick no-op (Solid's flush(fn) { fn() } in packages/frameworks/solid/src/machine.ts).
The Machine<T> the adapter consumes
The adapter's useMachine(machine, props) takes a Machine<T> (built by createMachine in packages/core/src/create-machine.ts, which just calls ensureStateIndex(config) for the state-path cache in packages/core/src/state.ts and returns the config unchanged). The adapter must read from it:
machine.props— normalizes/derives user props once per rendermachine.context— factory producing oneBindableper context key, called with abindableimplementation, ascope, aflush, and getters back into the adapter's ownctx/computed/refs/event(a closure-breaking pattern every adapter repeats identically)machine.computed— map of derivation functions keyed by computed namemachine.refs— factory for the refs bagmachine.initialState— resolves the starting state valuemachine.watch— called once per render/setup with the fullParams<T>object; internally callstrackmachine.entry/machine.exit/machine.effects— top-level lifecycle actions/effects, fired aroundINIT_STATEtransitions and unmountmachine.implementations.{actions,guards,effects}— the actual function bodies looked up by string key from transitions/state defsmachine.states/machine.on— consulted indirectly viafindTransition,getExitEnterStates,resolveStateValue,hasTag,matchesState(all imported from@zag-js/core, never reimplemented per framework)
Every adapter's useMachine body is structurally the same state machine driver (build scope → prop → context/ctx → refs → action/guard/effect/choose/computed closures → a state bindable whose onChange runs exit/entry effects+actions → a lifecycle hook that calls state.invoke(initial, INIT_STATE) → a send that looks up transitions and calls state.set). This driver logic is copy-pasted per framework, not shared — see the divergence map for exactly which lines are identical versus framework-specific.
The consumer layer: connect()
Reading .claude/docs/framework-integration-guide.md (written by the Zag team, not derived from adapter source) clarifies a layer this reference otherwise wouldn't surface: useMachine/Service<T>/normalizeProps are not the end of the story for a component author — every machine package (packages/machines/{component}/src/{component}.connect.ts) exports a connect(service, normalize) function that turns the raw Service<T> into a component-shaped API object (booleans like open, methods like setValue(), and getXProps() prop-getters that call normalize.button(...) / normalize.element(...) internally). This file lives in the machine package, not the framework adapter package — it is framework-agnostic itself, parameterized by whichever normalize: NormalizeProps<T> the caller passes in. A new framework adapter does not need to write anything for this layer beyond correctly implementing normalizeProps — every existing connect.ts file will work against a new adapter automatically as long as Service<T> and NormalizeProps<T> are honored.
Per-framework, the guide documents that connect()'s result needs to be kept reactive by the consumer, not by connect() itself: React uses it directly (no memo needed — a plain function call per render); Vue wraps it in computed(); Solid wraps it in createMemo(); Svelte 5 wraps it in $derived(). This is a call-site convention documented by the team, not something visible from the adapter source alone — a new adapter's docs/examples should state the equivalent convention for its target framework.
One discrepancy worth flagging: the guide's Solid section claims normalize.button({ onClick: () => {} }) produces { "on:click": () => {} } (event-delegation syntax). Reading the actual packages/frameworks/solid/src/normalize-props.ts in this tree shows no such conversion — its eventMap only remaps onFocus, onBlur, onDoubleClick, onChange, plus a few non-event props, and passes every other onX key through unchanged (Solid's JSX compiler handles onClick as a delegated event natively, without needing the normalizer to rename it). This example in the internal guide appears stale relative to the current source — trust the source cited throughout this reference over that one code sample.