The Three-Tag Pattern
Every Zag integration in marko-zag follows the same three steps, one tag
each. It is the Marko analog of Zag’s two-step useMachine + connect
idiom, with one extra tag that solves a Marko-specific problem (input
serialization — see below).
<machine-props>— picks the machine-owned props out of your
component’s input and returns a props closure.<service>— owns the machine lifecycle: creates and starts the real
service on the client, provides the SSR fallback recipe on the server.<connect>— derives the connected API (the object full of
get*Props()functions) and keeps it fresh across machine updates.
Overlay components add a fourth tag, <portal>, to render content at
document.body.
Worked example: a dialog
A complete, production-shaped dialog component wrapping
@zag-js/dialog (styling
trimmed for clarity — this is the exact wiring used by
marko-ui’s dialog):
/* dialog.marko */
import * as dialogMachine from "@zag-js/dialog";
import type { MachineInput } from "marko-zag";
export type Input = MachineInput<"div", dialogMachine.Props> & {
/** Marko-friendly sugar so callers can write `open:=state.showDialog` */
openChange?: (open: boolean) => void;
trigger?: Marko.Body<[Record<string, unknown>]>;
title?: Marko.Body;
content?: Marko.Body;
};
// 1. Pick the machine's props from this component's input.
// `pick=dialogMachine.props` is the machine's exported prop-NAME array —
// plain strings, so it serializes. Extra attributes written here are
// merged in: callback adaptations, inline overrides, anything.
<machine-props/machineProps from=input pick=dialogMachine.props
role="dialog"
onOpenChange(details: dialogMachine.OpenChangeDetails) {
input.onOpenChange?.(details);
input.openChange?.(details.open);
}/>
// 2. Run the machine. `machine=` MUST be a getter closure written here in
// the template — a raw machine object as tag input would hit Marko's
// serialization wall ("Unable to serialize input").
<service/service machine=() => dialogMachine.machine props=machineProps/>
// 3. Connect. The value shorthand holds the connect closure (again written
// in the template, for the same serialization reason). `api` is a getter
// you CALL at use sites: `api().getTriggerProps()`.
<connect/api=(service, normalizeProps) =>
dialogMachine.connect(service, normalizeProps)
service=service
/>
// Render: spread the prop getters onto native tags.
<if=input.trigger>
<${input.trigger}(api().getTriggerProps())/>
</if>
<portal>
<if=api().open>
<div ...api().getBackdropProps()/>
<div ...api().getPositionerProps()>
<div ...api().getContentProps()>
<if=input.title>
<h2 ...api().getTitleProps()><${input.title}/></h2>
</if>
<${input.content}/>
<button ...api().getCloseTriggerProps()>Close</button>
</div>
</div>
</if>
</portal>
<return=api>
Using it:
<let/open=false/>
<dialog open:=open>
<@trigger|triggerProps|><button ...triggerProps>Open dialog</button></@trigger>
<@title>Are you sure?</@title>
<@content>This action cannot be undone.</@content>
</dialog>
Why each tag exists
<machine-props> — the serialization-safe prop picker
Zag machines take a typed props object (id, open, onOpenChange, …).
Your Marko component’s input contains those props plus your own additions
(class, body content, sugar callbacks). <machine-props>:
- picks the machine-owned keys by name using the machine module’s exported
propsarray (dialogMachine.props) — a serializable string array,
where a splitter function would not survive Marko’s input serialization; - injects a stable generated
id(Zag requires one; callers may override
viainput.id); - merges every other attribute you write on the tag, which is where
chained callbacks live: theonOpenChangeabove forwards to both the
Zag-styleinput.onOpenChangeand the Marko-bind-style
input.openChange, so callers can useopen:=state.openshorthand.
It returns a closure (machineProps()), not a plain object — that is what
lets <service> re-read it reactively when controlled props change.
<service> — the machine lifecycle owner
The Marko analog of useMachine(machine, props). It returns a serializable
handle { service, machine, props, rev }:
serviceis the running machine on the client, andnullduring SSR —
the client builds its own instance inonMountand starts it.revis a counter bumped on every machine update, giving the handle fresh
identity so everything derived from it recomputes.machineandpropsare forwarded so<connect>can build the SSR
fallback.
It also watches your props closure: any reactive value read inside
machineProps() (a controlled open=, a changing disabled=) re-notifies
the machine automatically — no hand-typed dependency lists.
<connect> — the API deriver
The Marko analog of connect(service, normalizeProps). It returns the API
as a getter — you write api().getTriggerProps(), not
api.getTriggerProps() — so every read observes the latest machine state.
During SSR the handle carries no running service, so <connect> builds a
throwaway never-started one from the handle’s machine/props; Zag’s
connect() is a pure read, so this renders correct initial attributes with
zero DOM access.
Several <connect>s may share one <service> (e.g. connecting the same
machine’s API in a parent and a repeated child).
<portal> — SSR-safe overlay rendering
Renders its content inline on the server (inside a display: contents
host), then reparents the host to document.body (or a to= CSS selector
target) on mount. Marko tracks DOM nodes by reference, so the moved nodes
keep updating normally. Use it for dialog backdrops, toasts, menus —
anything that must escape ancestor overflow/z-index contexts.
Floating elements
Machines positioned by floating-ui (popover, menu, tooltip, select) need one
extra ingredient: a static style=positionerStyle after the positioner
spread. See positionerStyle and the
gotchas page for why.