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
- Create
packages/frameworks/<name>/package.jsonwithdependencieson@zag-js/core,@zag-js/types,@zag-js/utils, and (if you need a store-like primitive)@zag-js/store. Copy the shape frompackages/frameworks/vanilla/package.json— it has the fewest framework-specific devDependencies of the six. - Create
src/index.tsre-exportinguseMachine(or your class/constructor equivalent),normalizeProps, andmergePropsat minimum. See any of the sixindex.tsfiles — they're all 3-6 lines.
2. refs — trivial, do first
- Implement the
get/set-by-key box. Copy verbatim frompackages/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
- 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'sproxy()directly; it has zero framework dependencies and can be reused as-is. - Implement the full
Bindable<T>contract frompackages/core/src/types.ts:initial,ref,get(),set(value | fn),invoke(next, prev),hash(value). - Support the full
BindableParams<T>input:defaultValue,value(controlled flag =value !== undefined),hash,isEqual(defaultObject.is— five of six adapters agree on this default; only preact uses the looserisEqualutil, likely an oversight worth avoiding),onChange,debug,sync. - Attach the two static methods every adapter's
bindablefunction needs:bindable.cleanup(fn)(wire to your framework's unmount hook, or no-op if the caller owns lifecycle explicitly like vanilla) andbindable.ref(defaultValue)(a non-reactive get/set box, see vanilla's implementation — 6 lines).
4. normalizeProps — output-target specific
- 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).
- 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 frompackages/frameworks/vanilla/src/normalize-props.tsas the baseline if your target renders to real DOM attributes. - Handle
style: if your target wants a CSS string, reusetoStyleString()frompackages/frameworks/vanilla/src/normalize-props.ts(camelCase→kebab-case except--custom-props); if it wants a style object, reuse Solid'scssify()instead. - Handle
children: decide whether your renderer wantstextContent(solid-style),innerHTML(vue-style), or passthrough (react/preact/svelte-style — let the template layer deal with it). - 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. - Define a local
PropTypestype mapping your framework's intrinsic element prop types (or the raw DOM attribute shape, if untyped) to thePropTypes<T>shape expected byNormalizeProps<T>.
5. mergeProps
- If your target has no special class/style merging concerns, re-export
mergePropsfrom@zag-js/coredirectly (react/vue/preact do this — see any of theirindex.tsone-liners). Its handling ofon*handler chaining viacallAll,className/classconcatenation,styleobject/string merging, anddata-ownedbyunion-of-tokens is already contract-correct (packages/core/src/merge-props.ts). - Only write a wrapper if your output format needs post-processing — e.g. vanilla and svelte both wrap core's
mergePropsto stringify a merged style object viatoStyleString()after the fact (packages/frameworks/vanilla/src/merge-props.tsis 8 lines and a good template for "wrap core, then normalize style").
6. track — dependency-diffed side effects
- Implement a
track(deps: AnyFunction[], effect: VoidFunction)matching theTrackFntype inpackages/core/src/types.ts. Deps are functions; call each to get its current value. - Diff against the previous deps array with
isEqualfrom@zag-js/utils(notObject.is— deps are often objects/arrays). Only calleffect()when something changed, and never on the very first evaluation (every adapter skips the initial run — copy theisFirstRunflag pattern frompackages/frameworks/solid/src/track.ts, the shortest correct implementation at 20 lines). - 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 inmachine.ts'sgetParams()/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:
- Build
scopeviacreateScope({ id, ids, getRootNode })from@zag-js/core, memoized/recomputed wheneveruserPropschanges. - Build
prop: PropFn<T>— a function reading the live, normalized props object by key. Callmachine.props?.({ props: compact(userProps), scope })if defined, else fall back to rawuserProps. - 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. - Build
ctx: BindableContext<T>—get/set/initial/hashdelegating to each context bindable. - Build
refsvia your refs box (step 2), seeded frommachine.refs?.({ prop, context: ctx }). - Build the
action/guard/effect/choose/computedclosures — copy these five almost verbatim from any adapter; they contain no framework-specific code (see the divergence map's "identical" section). - Build the
statebindable with itsonChangehandler implementing the exit/entry lifecycle sequence — copy verbatim (see divergence map). - Wire your framework's mount hook (or an explicit
start()method) to callstate.invoke(state.initial!, INIT_STATE)and flipstatustoMachineStatus.Started. Decide whether you need react's microtask-deferral trick — you likely don't unless your framework has a StrictMode-like double-invoke behavior. - Wire your framework's unmount hook (or an explicit
stop()method) to run accumulated effect cleanups, cleartransition, and callaction(machine.exit). - Implement
send(event): guard onstatus === Started, look up the transition viafindTransition, resolve the target viaresolveStateValue, then eitherstate.set(target)(changed),state.invoke(current, current)(reenter), oraction(transition.actions)(same-state, no reenter). - Call
machine.watch?.(getParams())once per setup/render. - Return the
Service<T>object —state,send,context,prop,scope,refs,computed,event,getStatus— matching the exact shape inpackages/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
- Portal — only if your framework has no native out-of-tree rendering primitive. Reference:
packages/frameworks/react/src/portal.tsx(usescreatePortal) orpackages/frameworks/svelte/src/portal.ts(a Svelte action that manually callsappendChildand removes the node ondestroy). 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, aProxythat re-reads the source object on every property access).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.