How to Create a Marko-Zag Component
The complete recipe, using Switch — the reference implementation — as the worked example.
The mental model
A marko-zag component is three layers: a Zag machine supplies behavior and accessibility, three marko-zag tags wire it into Marko's resumability model, and Tailwind classes supply the look. The one rule everything else derives from: Marko resumes pages by serializing reactive state — it never re-runs your template in the browser. Zag's service and api objects contain functions from npm code and cannot be serialized, so they must never become reactive state or cross a tag boundary as raw values. Template-written closures can be serialized, which is why everything below passes machines and apis around as getters.
1. Name the files
A component lives in ui/<name>/<name>.marko (never index.marko — tabs and conversations need real names). Multi-part components add one file per part in the same directory (header.marko, content.marko…) plus variants.ts when there are cva variants, and registry.meta.json for registry metadata.
2. Type the Input
import * as switchMachine from "@zag-js/switch";
import type { MachineInput } from "marko-zag";
export type Input = MachineInput<"input", switchMachine.Props> & {
checkedChange?: (checked: boolean) => void;
};MachineInput<Tag, Props> is the native tag's attributes intersected with the machine's full Props, with only id made optional (it is generated). Everything Zag supports is automatically part of your API. Add one xxxChange prop per Zag onXxxChange callback — it enables Marko's two-way bind shorthand (<Switch checked:=state/>) and receives the plain value; Zag's original callback remains available and receives the details object.
3. Wire the machine — three tags
<machine-props/machineProps from=input pick=switchMachine.props
onCheckedChange(details: switchMachine.CheckedChangeDetails) {
input.onCheckedChange?.(details);
input.checkedChange?.(details.checked);
}/>
<service/service machine=() => switchMachine.machine props=machineProps/>
<connect/api=(service, normalizeProps) =>
switchMachine.connect(service, normalizeProps)
service=service
/><machine-props>builds the machine's props closure: picks machine-owned props out ofinputby name (pick=switchMachine.props— the machine's exported name array; a split function would be unserializable tag input), injects a generated stableid, and merges every attribute you write on the tag. Callback adaptations go here, visibly — they are the component's public contract. Type thedetailsparams with the machine's exported types.<service>creates and owns the running machine on the client; during SSR it stays unstarted and connect reads a throwaway instance — which is how correctaria-*/data-*attributes appear in server HTML before any JS runs. Controlled props re-notify automatically: the tag tracks whatever yourmachinePropsclosure reads.<connect>returnsapias a getter. Call it at every use site:api().getRootProps(). The getter has fresh identity per machine update, so every spread that reads it recomputes.
Both machine= and the <connect> value must be closures written in your template. Passing switchMachine.machine or switchMachine.connect directly throws Unable to serialize "input".
4. Render with api() spreads
<label ...api().getRootProps() data-slot="switch-label" class="...">
<span ...api().getControlProps() data-slot="switch" class=cn("...", input.class)>
<span ...api().getThumbProps() data-slot="switch-thumb" class="..."/>
</span>
<input ...nativeAttrs() ...api().getHiddenInputProps()>
</label>
<return=api>- Faithful shadcn Tailwind classes; semantic tokens only — never raw colors.
- Every part carries a
data-slot; style states via Zag'sdata-[state=...]attributes. - Native pass-through (
aria-*,data-*, form attributes) =splitProps(input)[1]minusclassand yourxxxChangeprops, spread onto the appropriate native element. <return=api>exposes the live api getter to parents.
5. Overlay components
- Floating content goes in
<portal><if=api().open>...— portal outside, condition inside (stable host, no orphan accumulation). - Popper-positioned parts (popover, tooltip, menu, select…) spread
...api().getPositionerProps()then add STATICstyle=positionerStyle(from marko-zag): Zag positions via imperative--x/--yCSS vars that a reactive style attribute would wipe. - Never wrap caller-supplied triggers in your own
<button>— nested buttons get un-nested by the HTML parser and corrupt hydration. Use a render-prop:trigger: Marko.Body<[Record<string, unknown>]>, rendered as<${input.trigger}(api().getTriggerProps())/>.
6. Verify
curlthe demo route: 200, SSR carries the expectedaria-*/data-stateattributes, and noUnable to serializeanywhere.- Real-browser check: interactions need window focus (machines often require focus-then-click); synthetic
element.click()can miss. - Formatter landmine: prettier-plugin-marko can turn multi-line attribute arrows into block bodies without a return — keep attribute closures single-expression and re-verify after formatting.
Reference
The living version of all of this is packages/registry/default/ui/switch/switch.marko — copy it. Adapter internals: the Zag adapter anatomy.