marko-ui

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
/>

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>

5. Overlay components

6. Verify

Reference

The living version of all of this is packages/registry/default/ui/switch/switch.marko — copy it. Adapter internals: the Zag adapter anatomy.