Click, hover, and load behavior for Button, Link, Container, Text, and Image nodes. One declarative action array on the node; optional custom JS in handlers; optional attrs for raw DOM passthrough.
Looking for the full TypeScript types? See
NodeActioninaction.tsandConditioninconditions/types.ts. This doc explains what each piece is for and when to reach for it.
{
"type": "Button",
"props": {
"text": "Contact us",
"action": [{ "type": "link", "href": "/contact" }]
}
}
That's it. One action in the array, a type, and whatever fields that type needs.
The same array shape supports chains, gating, conversion tracking, and any trigger:
"action": [
// Copy a value
{ "type": "copy-to-clipboard", "text": "{{item.snippet}}" },
// Dismiss a banner and remember the dismissal
{ "type": "show-hide", "target": "promo_banner", "direction": "hide" },
{ "type": "set-local-storage", "key": "ph-promo-dismissed", "value": "1" },
// Only navigate if a state flag is set, and track the conversion
{
"type": "link",
"href": "/pricing",
"conditions": [{
"logic": "all",
"conditions": [
{ "type": "state", "key": "promo:accepted", "operator": "equals", "value": "true" }
]
}],
"conversion": {
"provider": "google-ads",
"eventName": "conversion",
"sendTo": "AW-1234567890/AbC-D_xYz"
}
}
]
Everything below explains each piece.
props.action is always NodeAction[] — a flat array of action objects that fire in order on the same event.
| Component | Field | Notes |
|---|---|---|
| Button | props.action | Renders as <button> or <a> depending on whether link is present. |
| Link | props.action | Only link actions are valid here. Use Button for other types. |
| Container | props.action | Whole div becomes clickable. Renders children (unlike Button). |
| Text | props.action | The text element gets the click handler. |
| Image | props.action | The <img> (or its wrapper) gets the click handler. |
A single action is still wrapped: action: [{ type: "link", href: "/x" }], not action: { ... }. The runtime normalizes legacy single-object and missing-array shapes through migrateActions(props), but you should always write arrays.
Execution order on a single event: actions fire left-to-right. A link action's body is e.preventDefault() then window.location.assign(href) — so any actions you want to run before navigation must come earlier in the array. The <a> tag is given the first link's href so right-click "open in new tab", screen readers, and crawlers all work.
Editor suppression: every action body is suppressed when the SDK is running in editor mode (enabled === true). Authors don't fire their own JS while designing.
Most actions fire on click, but show-hide (the most flexible primitive) supports three triggers:
| Trigger | When it fires | Common use |
|---|---|---|
click | Default. Pointer click / Enter on focused. | Buttons, links, "open menu". |
hover | pointerenter. | Dropdowns, mega menus, hover-preview cards. |
load | Once on mount in viewer mode. | First-visit banners, cookie consent, popups. |
load triggers are skipped in the editor — see useShowOnLoadAutoReveal, which instead writes "shown" to the show-hide store so authors can edit banners regardless of localStorage state.
The full set, grouped by what you're trying to do.
link — the only navigation type you need. The href shape selects the behavior; see the unified link action section below for all the forms.
{ "type": "link", "href": "ref:page_shop" }
show-hide — toggle any node by id. Powers tabs, accordions, drawers, modals, and load-trigger banners. The class-based method ("class") is reactive via the SDK's showHideStore. Target the same id from multiple buttons to wire many triggers to one panel.
{ "type": "show-hide", "target": "mobile-nav", "direction": "toggle", "method": "class" }
Direction: "show", "hide", "toggle", or "tab" (mutually-exclusive within a group). Method: "class" (recommended, reactive) or "style" (legacy, sets inline display).
open-modal — convenience wrapper around show-hide for modal patterns. Most templates use show-hide directly.
The four state actions read and write to the central in-memory state registry. Pair them with a state condition or props.stateModifiers binding to make nodes react. See state-system.md for the full author flow.
set-state — write a value. value interpolates {{item.*}} against the current repeater item.
{ "type": "set-state", "key": "url:category", "value": "tees" }
toggle-state — flip between two values. Defaults: kind: "visibility" toggles "shown"|"hidden"; kind: "flag" toggles "on"|"off". Other kinds need explicit values: [a, b] or the action skips (with a warn).
{ "type": "toggle-state", "key": "cart:open", "kind": "visibility" }
increment-state / decrement-state — add or subtract from a numeric state value, with optional step (default 1), min, and max clamps. Used for pagination.
{ "type": "increment-state", "key": "url:page", "step": 1, "min": 1 }
clear-state — remove an entry entirely. Useful for "Reset filters" buttons.
{ "type": "clear-state", "key": "url:category" }
set-local-storage — write key → value to window.localStorage. Pairs with a load-trigger show-hide that gates on the same key via a localStorage condition with not-exists.
{ "type": "set-local-storage", "key": "ph-cookie-consent", "value": "accepted" }
remove-local-storage — delete a key. Used for "log out everywhere" or "show me the popup again".
add-to-cart — adds the current item (resolved from the repeater context) to the storefront cart. Supports a quantityField that walks up to the nearest form / section / body ancestor and reads [name="<quantityField>"]. Falls back to quantity (static) or 1. For multi-variant products, point variantMatchStateKey at a state key populated by a variant-match computed binding.
{ "type": "add-to-cart", "quantityField": "qty" }
toggle-cart — opens / closes the cart drawer. Writes the cart:open visibility entry; the drawer's backdrop binds to it via Container.visibilityStateKey.
cart-checkout — navigates to Stripe checkout with the current cart contents.
manage-subscription — opens the Stripe customer portal for the logged-in site customer.
toggle-theme — flips the site between light and dark, persists to localStorage. No params.
{ "type": "toggle-theme" }
copy-to-clipboard — copies a value. The text field interpolates {{item.*}}.
{ "type": "copy-to-clipboard", "text": "{{item.code}}" }
download-file — triggers a download for a given URL.
{ "type": "download-file", "href": "/files/whitepaper.pdf", "filename": "whitepaper.pdf" }
agent-send — sends a prompt to an embedded agent. Used inside agent chat blocks and "suggested prompts" chips.
{ "type": "agent-send", "prompt": "Tell me about pricing" }
link-url, link-page, scroll-to, email, phone still render via runtime shim, but do not write them in new content. The unified link action covers every case.
link actionOne action type, eight href shapes. The renderer picks the right behavior from the prefix.
| Shape | Example | What happens |
|---|---|---|
| Internal page | ref:page_shop | Renderer rewrites to the correct path per context (/view/<siteId>/shop in preview, /shop on a custom domain). |
| Internal page + dynamic suffix | ref:page_product/{{item.slug}} | {{item.*}} interpolates against the current repeater item. Also accepts ?query and #hash suffixes. |
| Internal URL with query | /shop?category=tees | Host routing handles it directly. |
| External | https://example.com | Pair with target: "_blank" for a new tab. |
| In-page anchor | #features | Renderer attaches preventDefault + smooth-scroll. Works regardless of position in a chain. |
mailto:hello@example.com?subject=Inquiry | Standard mailto querystring. | |
| Phone | tel:+15551234567 | Opens the dialer on mobile. |
| SMS | sms:+15551234567 | Opens the messaging app. |
Fast path: a single link action with a static href and no conversion tracking renders as a plain <a href> with no JS — the browser navigates natively. As soon as you add a chain, conditions, or conversion, the dispatcher takes over and uses e.preventDefault() + window.location.assign(href).
Interpolation: the href, target, and attrs values interpolate {{item.*}} against the current repeater item context. Use this for category chips, product cards, blog post links — anywhere a list of items wires the same template to different destinations.
// Inside a Data node iterating over Stripe products:
{
"type": "Button",
"props": {
"text": "{{item.title}}",
"action": [{ "type": "link", "href": "ref:page_product/{{item.slug}}" }]
}
}
conditions)Every action carries an optional conditions: ConditionGroup[] field. The action only fires if the conditions pass. Same shape and evaluator as node-level visibility and page-level access — one mechanism, three surfaces.
{
"type": "link",
"href": "/dashboard",
"conditions": [{
"logic": "all",
"conditions": [
{ "type": "auth", "key": "status", "operator": "equals", "value": "authenticated" }
]
}]
}
Multiple groups are OR'd (Elementor-style). Conditions within a group AND or OR via logic: "all" | "any".
The gate check lives in actionGatePasses and runs uniformly across click, hover, and load triggers. The same evaluator powers props.stateModifiers (for state-driven styling) and page access control.
| Type | What it checks | key is… | Example |
|---|---|---|---|
url-param | A ?query= value on the current URL. | Param name (e.g. "ref"). | Show a special banner when ?ref=email. |
auth | Site-customer auth state. | Dot-path: "status", "customer.hasSubscription", "customer.orderCount". | "Login" button visible only when logged out. |
device | Viewport size (mobile vs desktop). | Always "viewport". value is "mobile" or "desktop". | Different CTA copy on phones. |
connector | Whether a connector binding has data, or count comparisons. | Dot-path like "stripe.products". | Show "Out of stock" message if stripe.products count < 1. |
company | Company variables on ROOT (name, email, phone, etc). | Field name like "name", "phone". | Hide the phone CTA when no phone is set. |
form-field | A live value from a <form> ancestor. | Field name. Optional target anchor for cross-form watching. | Enable submit only when terms checkbox is checked. |
item | The current repeater item (inside a Data node). | Dot-path into the item (e.g. "description", "images.1"). | Hide the hover-image swap when an item has only one image. |
localStorage | A raw window.localStorage key. | The key string (no ph- prefix needed). | First-visit gate on cookie banners. |
state | The central state registry. | A state key — element id or named state. Supports {{anchor.X}} tokens. | Hide "Add" if pdp:current:matching-variant is empty. |
Operators per type are documented in OPERATORS_BY_TYPE. Available operators: equals, not-equals, contains, not-contains, exists, not-exists, greater-than, less-than. Not every type supports every operator; the editor's ActionConditionsEditor only shows the valid combinations.
Conditions are how you express "only on first visit", "only logged in", "only on mobile", "only with ?ref=email", and combinations thereof:
"conditions": [{
"logic": "all",
"conditions": [
{ "type": "localStorage", "key": "ph-cookie-consent", "operator": "not-exists", "value": "" },
{ "type": "device", "key": "viewport", "operator": "equals", "value": "mobile" }
]
}]
That's "show only if no cookie consent exists AND viewport is mobile". Add a second group to the outer array for "OR also show when…".
props.handlers)When a declarative action can't express what you need, drop down to a JS string. Container, Button, and Text can declare handlers:
"handlers": {
"onClick": "event.stopPropagation()",
"onMouseEnter": "console.log('hover', event.currentTarget.id)"
}
Compilation: each value is compiled once as new Function("event", code) and wired as the real React event prop. The keys must match /^on[A-Z]/ — React event names like onClick, onSubmit, onKeyDown, onMouseEnter, onMouseLeave, onFocus, onBlur.
Execution order: if the same node has both an action and a handlers entry for the same event, the action fires first, then the handler. Both receive the same event. This means a handler can react to whatever an action just did (e.g. read the new state, call event.preventDefault() to cancel a navigation).
Editor suppression: like actions, handlers are suppressed when the SDK is in editor mode. You won't accidentally stopPropagation() your own toolbar click.
Static export: handlers emit as native HTML attributes (onclick="…") in the published HTML. No React runtime needed.
Authoring: the unified Action sidebar shows handler chips below the action chips. The + Add Handler button opens a SearchableMenuPopover listing available events; each chip opens its own HandlerEditorPanel with an event dropdown and a CodeMirror JS editor. Editing block JSON directly works the same — just write the object.
Event keys do NOT belong in
attrs— React rejects string event handlers, soattrs: { "onClick": "..." }silently does nothing. Usehandlers.
| Need | Use |
|---|---|
| Anything in the action-type list above. | action — declarative, gateable, conversion-trackable. |
event.stopPropagation() to keep a click inside a card. | handlers.onClick. |
event.preventDefault() to cancel native browser behavior. | handlers. |
Read DOM (event.currentTarget.dataset.x). | handlers. |
Backdrop close on a modal (event.target === event.currentTarget). | handlers.onClick. |
| Anything that's "fire after my action". | handlers (runs after action). |
props.attrs — DOM passthroughFor raw DOM attributes that aren't first-class props:
"attrs": { "id": "cart-drawer", "role": "search", "autocomplete": "off", "data-tab-group": "gallery" }
Values must be string | number | boolean. Button interpolates string values against {{item.*}} so dynamic data-attrs work inside repeaters.
Used for:
id on show-hide targets — Craft node IDs do NOT become DOM id attributes; you must put the id here for show-hide: { target: "..." } to find it.role, aria-*, autocomplete — accessibility and form behavior.data-* — for CSS hooks, JS hooks, and the data-tab-group show-hide grouping.First-visit cookie consent, newsletter popups, "we use cookies" disclaimers — all the same pattern. A banner Container with a show-hide action on trigger: "load", gated by a localStorage not-exists condition. The dismiss button writes the same key via set-local-storage.
// 1. Banner Container — child of ROOT, anchor === target id
{
"type": "Container",
"props": {
"anchor": "cookie-consent",
"className": "hidden fixed bottom-0 left-0 right-0 z-[1100] bg-base-200",
"attrs": { "id": "cookie-consent" },
"action": [{
"type": "show-hide",
"target": "cookie-consent",
"direction": "show",
"trigger": "load",
"method": "class",
"conditions": [{
"logic": "all",
"conditions": [
{ "type": "localStorage", "key": "ph-cookie-consent", "operator": "not-exists", "value": "" }
]
}]
}]
}
}
// 2. Accept button — hides the banner and writes the gate key
{
"type": "Button",
"props": {
"text": "Accept",
"action": [
{ "type": "show-hide", "target": "cookie-consent", "direction": "hide", "method": "class" },
{ "type": "set-local-storage", "key": "ph-cookie-consent", "value": "accepted" }
]
}
}
Where it actually fires per runtime:
/build preview, /view, /static, custom domains) — the Container's mount effect calls fireLoadAction for each load-trigger action. Conditions are evaluated through evaluateConditionGroups with auth, URL params, and viewport context. Skipped on false, fired on true or null (graceful "show by default" when the condition can't be evaluated).enabled === true) — mount effect early-returns. useShowOnLoadAutoReveal writes "shown" to the show-hide store so authors can see and edit the banner regardless of localStorage state.Container.toHTML stamps data-ph-load-show="" + data-ph-load-conditions="<json>" on target elements. PH_LOAD_ACTION_SCRIPT — one inline script, conditionally concatenated when ctx.hasLoadActions — evaluates conditions client-side before paint and strips hidden on match.// Copy code, then send the user to the docs page
"action": [
{ "type": "copy-to-clipboard", "text": "{{item.snippet}}" },
{ "type": "link", "href": "/docs#install" }
]
// Add to cart and open the cart drawer
"action": [
{ "type": "add-to-cart" },
{ "type": "toggle-cart" }
]
// Dismiss banner, write the gate key, navigate to /pricing
"action": [
{ "type": "show-hide", "target": "promo_banner", "direction": "hide" },
{ "type": "set-local-storage", "key": "ph-promo-dismissed", "value": "1" },
{ "type": "link", "href": "/pricing" }
]
// Reset filters and scroll back to top
"action": [
{ "type": "clear-state", "key": "url:category" },
{ "type": "clear-state", "key": "url:q" },
{ "type": "set-state", "key": "url:page", "value": "1" },
{ "type": "link", "href": "#top" }
]
show-hide with direction: "tab" is the primitive. Each slide Container needs a unique id (via attrs.id) and attrs: { "data-tab-group": "<group>" }. Each thumbnail is a Container (NOT Button — Button doesn't render child nodes) with:
"action": [{
"type": "show-hide",
"target": "<slide-id>",
"direction": "tab",
"group": "<group>",
"method": "class"
}]
Start non-first slides with hidden in className. The runtime hides every other tab in the same group when one is shown.
Every action carries an optional conversion?: ActionConversion field that fires gtag('event',…) or fbq('track',…) after the action runs. Forms have the same field on their root props.
interface ActionConversion {
provider: "google-ads" | "ga4" | "meta";
eventName: string;
sendTo?: string; // REQUIRED for google-ads — "AW-XXXXXXXXXX/AbCdEfGhIj"
value?: number;
currency?: string; // ISO 4217
}
Setup: Site Settings → Integrations: paste your Google Ads Conversion ID (AW-…), GA4 Measurement ID (G-…), and/or Meta Pixel ID. Then open the action editor on a Button / Link / Container / Form and expand Track conversion.
Provider semantics:
google-ads → gtag('event','conversion',{ send_to, value, currency }). eventName is ignored — Google Ads conversions are always the "conversion" event type. sendTo is REQUIRED.ga4 → gtag('event', eventName, { value, currency }).meta → fbq('track', eventName, { value, currency }).Navigation-aware behavior: same-tab link actions defer navigation through gtag's event_callback so the beacon flushes before window.location.assign. A 1 s safety timer guarantees navigation if the callback never resolves (gtag stubbed, blocked network). A fired flag prevents the callback + timer from double-navigating.
target="_blank" and tel: / mailto: are fire-and-forget — the popup/dialer opens synchronously inside the user-gesture handler (popup blockers would kill a deferred window.open), then the gtag call runs.
Smart editor presets seed the event name based on the action:
| Trigger | Preset |
|---|---|
link with tel: href | Phone Call |
link with mailto: href | Contact |
link with maps.google / maps.apple / google.com/maps href | Get Directions |
add-to-cart action | AddToCart |
cart-checkout action | InitiateCheckout |
download-file action | Download |
| Form (any submitType) | Lead |
| Fallback | Click |
// Phone CTA on an ads LP — fires Google Ads conversion before dialer opens
{
"type": "Button",
"props": {
"text": "Call (818) 500-1770",
"action": [{
"type": "link",
"href": "tel:+18185001770",
"conversion": {
"provider": "google-ads",
"eventName": "conversion",
"sendTo": "AW-1234567890/AbC-D_xYz"
}
}]
}
}
The class-based show-hide method is reactive via the SDK's showHideStore. Defaults: method: "class", overlay starts with hidden in className, ESC is automatic (global stack), backdrop close uses props.handlers.onClick filtering event.target === event.currentTarget. The ONLY required inject is one CSS rule (#target:not(.hidden) { display: flex }) because twMerge collapses hidden + flex (same display group). No vanilla JS listeners.
See .claude/known-issues/modal-show-hide-gotchas.md for the full pattern.
data-action discovery attributeComponents with actions stamp a whitespace-separated data-action attribute carrying every action's type (data-action="add-to-cart toggle-cart"). Query with [data-action~='<type>'] (CSS word-match) so multi-action buttons are still discoverable. The cart badge injector in components/stripe/cartContext.tsx is the canonical example.
{{item.*}} inside href, text, attrs → data-bindings.mdset-state / toggle-state and state conditions → state-system.md