marko-ui

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

1. The adapter contract

Every framework package under packages/frameworks/*/src exports the same minimal surface. Confirmed from each package's index.ts:

Exportreactsolidsveltevuepreactvanilla
useMachine (or VanillaMachine class)yesyesyesyesyesVanillaMachine
normalizePropsyesyesyesyesyesyes
mergePropsre-export of @zag-js/coreown impl (accessor-aware)own impl (class handling + style)re-export of corere-export of coreown impl (style stringify)
useSyncExternalStorere-export from reactown implown impl (.svelte.ts)own implown impl (wraps preact/compat)n/a (uses subscribe/publish instead)
Portal / portalyes (portal.tsx)noyes (Svelte action, portal.ts)no (not present in src/)declared in index.ts but no portal.tsx file exists in src/no
reflectnonoyes (reflect.ts, Proxy that rebinds methods each access)nonono
spreadPropsno (JSX handles spreading)nonononoyes (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:

FieldType contract (core/src/types.ts)Purpose
stateBindable<T["state"]> & { matches, hasTag }Current machine state as a bindable cell, plus state-value helpers
send(event: EventType<T["event"]>) => voidDispatch an event into the machine
contextBindableContext<T>get/set/initial/hash per keyControlled/uncontrolled context values (bindables)
propPropFn<T><K>(key: K) => T["props"][K]Read the live, framework-reactive props object by key
scopeScopegetRootNode/getDoc/getWin/getById/getActiveElement/isActiveElementDOM environment abstraction (see packages/core/src/scope.ts)
refsBindableRefs<T>get/set by keyMutable, non-reactive machine-owned refs (e.g. timers, DOM handles)
computedComputedFn<T><K>(key: K) => T["computed"][K]Derived values recomputed on each access from machine.computed
eventEventType<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:

Every adapter's useMachine body is structurally the same state machine driver (build scopepropcontext/ctxrefsaction/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.