marko-ui

Checklist for a new adapter

Everything a seventh framework adapter must implement, in build order, each item pointing at the reference implementation file most worth copying from.

File paths below are relative to data/zag. "Copy from" picks the adapter whose implementation is closest to contract-only (least framework-specific noise) for that piece — usually vanilla or solid, since neither depends on a compiler step.

1. Scaffolding

  1. Create packages/frameworks/<name>/package.json with dependencies on @zag-js/core, @zag-js/types, @zag-js/utils, and (if you need a store-like primitive) @zag-js/store. Copy the shape from packages/frameworks/vanilla/package.json — it has the fewest framework-specific devDependencies of the six.
  2. Create src/index.ts re-exporting useMachine (or your class/constructor equivalent), normalizeProps, and mergeProps at minimum. See any of the six index.ts files — they're all 3-6 lines.

2. refs — trivial, do first

  1. Implement the get/set-by-key box. Copy verbatim from packages/frameworks/vanilla/src/refs.ts (12 lines, no framework dependency) — identical to solid/vue/svelte's versions. Only wrap in a framework ref/cell if your target needs stable object identity across re-invocations of the setup function.

3. bindable — the core reactive primitive

  1. Decide what reactive storage primitive your framework offers (signal, ref, rune, proxied store, or — if none — a manual pub/sub store). If none, copy packages/frameworks/vanilla/src/bindable.ts's use of @zag-js/store's proxy() directly; it has zero framework dependencies and can be reused as-is.
  2. Implement the full Bindable<T> contract from packages/core/src/types.ts: initial, ref, get(), set(value | fn), invoke(next, prev), hash(value).
  3. Support the full BindableParams<T> input: defaultValue, value (controlled flag = value !== undefined), hash, isEqual (default Object.is — five of six adapters agree on this default; only preact uses the looser isEqual util, likely an oversight worth avoiding), onChange, debug, sync.
  4. Attach the two static methods every adapter's bindable function needs: bindable.cleanup(fn) (wire to your framework's unmount hook, or no-op if the caller owns lifecycle explicitly like vanilla) and bindable.ref(defaultValue) (a non-reactive get/set box, see vanilla's implementation — 6 lines).

4. normalizeProps — output-target specific

  1. Determine what DOM-facing convention your framework's render output expects: real DOM attribute names (vanilla/svelte-style, all lowercase except an SVG-case allowlist), a template compiler's prop names (Vue-style), or JSX camelCase passthrough (react/preact-style).
  2. Build a propMap: Record<string, string> for the handful of renames every non-JSX adapter needs: onFocus, onBlur, onChange, onDoubleClick, htmlFor, className, defaultValue, defaultChecked. Copy the map from packages/frameworks/vanilla/src/normalize-props.ts as the baseline if your target renders to real DOM attributes.
  3. Handle style: if your target wants a CSS string, reuse toStyleString() from packages/frameworks/vanilla/src/normalize-props.ts (camelCase→kebab-case except --custom-props); if it wants a style object, reuse Solid's cssify() instead.
  4. Handle children: decide whether your renderer wants textContent (solid-style), innerHTML (vue-style), or passthrough (react/preact/svelte-style — let the template layer deal with it).
  5. Wrap the transform in createNormalizer<PropTypes>(fn) from @zag-js/types (packages/types/src/prop-types.ts) — do not reimplement the Proxy-based per-tag dispatch; every adapter reuses this unchanged.
  6. Define a local PropTypes type mapping your framework's intrinsic element prop types (or the raw DOM attribute shape, if untyped) to the PropTypes<T> shape expected by NormalizeProps<T>.

5. mergeProps

  1. If your target has no special class/style merging concerns, re-export mergeProps from @zag-js/core directly (react/vue/preact do this — see any of their index.ts one-liners). Its handling of on* handler chaining via callAll, className/class concatenation, style object/string merging, and data-ownedby union-of-tokens is already contract-correct (packages/core/src/merge-props.ts).
  2. Only write a wrapper if your output format needs post-processing — e.g. vanilla and svelte both wrap core's mergeProps to stringify a merged style object via toStyleString() after the fact (packages/frameworks/vanilla/src/merge-props.ts is 8 lines and a good template for "wrap core, then normalize style").

6. track — dependency-diffed side effects

  1. Implement a track(deps: AnyFunction[], effect: VoidFunction) matching the TrackFn type in packages/core/src/types.ts. Deps are functions; call each to get its current value.
  2. Diff against the previous deps array with isEqual from @zag-js/utils (not Object.is — deps are often objects/arrays). Only call effect() when something changed, and never on the very first evaluation (every adapter skips the initial run — copy the isFirstRun flag pattern from packages/frameworks/solid/src/track.ts, the shortest correct implementation at 20 lines).
  3. Hook it to whatever your framework's smallest reactive-recompute unit is (an effect, a watcher). If there is none, tie it to your publish/notify pipeline directly, as vanilla does inline in machine.ts's getParams()/callTrackers().

7. The machine driver (useMachine / constructor)

This is the biggest single file. Build it in this internal order, referencing packages/frameworks/vanilla/src/machine.ts as the least-framework-encumbered reference implementation throughout:

  1. Build scope via createScope({ id, ids, getRootNode }) from @zag-js/core, memoized/recomputed whenever userProps changes.
  2. Build prop: PropFn<T> — a function reading the live, normalized props object by key. Call machine.props?.({ props: compact(userProps), scope }) if defined, else fall back to raw userProps.
  3. Call machine.context?.({ prop, bindable, scope, flush, getContext, getComputed, getRefs, getEvent }) — note the getter-based back-references into your own not-yet-fully-built closures (ctx, computed, refs); every adapter ties these knots the same way.
  4. Build ctx: BindableContext<T>get/set/initial/hash delegating to each context bindable.
  5. Build refs via your refs box (step 2), seeded from machine.refs?.({ prop, context: ctx }).
  6. Build the action/guard/effect/choose/computed closures — copy these five almost verbatim from any adapter; they contain no framework-specific code (see the divergence map's "identical" section).
  7. Build the state bindable with its onChange handler implementing the exit/entry lifecycle sequence — copy verbatim (see divergence map).
  8. Wire your framework's mount hook (or an explicit start() method) to call state.invoke(state.initial!, INIT_STATE) and flip status to MachineStatus.Started. Decide whether you need react's microtask-deferral trick — you likely don't unless your framework has a StrictMode-like double-invoke behavior.
  9. Wire your framework's unmount hook (or an explicit stop() method) to run accumulated effect cleanups, clear transition, and call action(machine.exit).
  10. Implement send(event): guard on status === Started, look up the transition via findTransition, resolve the target via resolveStateValue, then either state.set(target) (changed), state.invoke(current, current) (reenter), or action(transition.actions) (same-state, no reenter).
  11. Call machine.watch?.(getParams()) once per setup/render.
  12. Return the Service<T> object — state, send, context, prop, scope, refs, computed, event, getStatus — matching the exact shape in packages/core/src/types.ts.

8. Reactivity bridge (the one piece with no reference to copy)

Decide how consumers get notified. See the divergence map's reactivity-trigger row: this is the one axis every adapter solves completely differently because it's entirely framework/runtime-shaped. If your target has no ambient reactivity, replicate vanilla's subscribe/publish pattern (packages/frameworks/vanilla/src/machine.ts, notify/publish/subscriptions) plus its spreadProps-style imperative DOM reconciler (packages/frameworks/vanilla/src/spread-props.ts) for actually applying normalized props to elements — see the vanilla deep-dive for the full breakdown.

9. Optional utilities — build only if your target needs them

  1. Portal — only if your framework has no native out-of-tree rendering primitive. Reference: packages/frameworks/react/src/portal.tsx (uses createPortal) or packages/frameworks/svelte/src/portal.ts (a Svelte action that manually calls appendChild and removes the node on destroy).
  2. reflect() — only if your template compiler snapshots destructured/spread object properties instead of keeping them live. Reference: packages/frameworks/svelte/src/reflect.ts (13 lines, a Proxy that re-reads the source object on every property access).
  3. spreadProps — only if you render to real DOM elements imperatively rather than through a compiler/VDOM. Reference: packages/frameworks/vanilla/src/spread-props.ts.

10. Verify against the test fixtures

Every existing adapter ships tests/machine.test.ts, tests/nested-states.test.ts, and (where applicable) tests/bindable.test.ts / tests/normalize-props.test.ts / tests/merge-props.test.ts. These exercise the state-machine driver against real machine definitions and are the fastest way to confirm a new adapter's useMachine honors the same transition/lifecycle contract as the existing six — start from packages/frameworks/vanilla/tests/ since it requires no DOM testing library setup beyond jsdom.