Actions & handlers

link, show-hide, set-state, add-to-cart, conditions, handler chains.

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 NodeAction in action.ts and Condition in conditions/types.ts. This doc explains what each piece is for and when to reach for it.


The smallest action that works

{
  "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.


A fully wired action

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.


Shape

props.action is always NodeAction[] — a flat array of action objects that fire in order on the same event.

ComponentFieldNotes
Buttonprops.actionRenders as <button> or <a> depending on whether link is present.
Linkprops.actionOnly link actions are valid here. Use Button for other types.
Containerprops.actionWhole div becomes clickable. Renders children (unlike Button).
Textprops.actionThe text element gets the click handler.
Imageprops.actionThe <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.


Trigger types

Most actions fire on click, but show-hide (the most flexible primitive) supports three triggers:

TriggerWhen it firesCommon use
clickDefault. Pointer click / Enter on focused.Buttons, links, "open menu".
hoverpointerenter.Dropdowns, mega menus, hover-preview cards.
loadOnce 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.


Action types

The full set, grouped by what you're trying to do.

Navigation

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" }

Visibility

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.

State

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" }

Storage

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".

Commerce

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.

Theme

toggle-theme — flips the site between light and dark, persists to localStorage. No params.

{ "type": "toggle-theme" }

Utility

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" }

AI / agent

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" }

Legacy types — DO NOT EMIT

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.


The unified link action

One action type, eight href shapes. The renderer picks the right behavior from the prefix.

ShapeExampleWhat happens
Internal pageref:page_shopRenderer rewrites to the correct path per context (/view/<siteId>/shop in preview, /shop on a custom domain).
Internal page + dynamic suffixref:page_product/{{item.slug}}{{item.*}} interpolates against the current repeater item. Also accepts ?query and #hash suffixes.
Internal URL with query/shop?category=teesHost routing handles it directly.
Externalhttps://example.comPair with target: "_blank" for a new tab.
In-page anchor#featuresRenderer attaches preventDefault + smooth-scroll. Works regardless of position in a chain.
Emailmailto:hello@example.com?subject=InquiryStandard mailto querystring.
Phonetel:+15551234567Opens the dialer on mobile.
SMSsms:+15551234567Opens 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}}" }]
  }
}

Action gating (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.

Condition types

TypeWhat it checkskey is…Example
url-paramA ?query= value on the current URL.Param name (e.g. "ref").Show a special banner when ?ref=email.
authSite-customer auth state.Dot-path: "status", "customer.hasSubscription", "customer.orderCount"."Login" button visible only when logged out.
deviceViewport size (mobile vs desktop).Always "viewport". value is "mobile" or "desktop".Different CTA copy on phones.
connectorWhether a connector binding has data, or count comparisons.Dot-path like "stripe.products".Show "Out of stock" message if stripe.products count < 1.
companyCompany variables on ROOT (name, email, phone, etc).Field name like "name", "phone".Hide the phone CTA when no phone is set.
form-fieldA live value from a <form> ancestor.Field name. Optional target anchor for cross-form watching.Enable submit only when terms checkbox is checked.
itemThe 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.
localStorageA raw window.localStorage key.The key string (no ph- prefix needed).First-visit gate on cookie banners.
stateThe 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.

Stacking gates

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…".


Custom JS handlers (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, so attrs: { "onClick": "..." } silently does nothing. Use handlers.

When to reach for handlers vs actions

NeedUse
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 passthrough

For 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.

Load-trigger banners (recipe)

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:

  • React routes (/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).
  • Editor (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.
  • Static export — no React. 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.

Multi-action chain recipes

// 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" }
]

Click-to-swap galleries

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.


Conversion tracking

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-adsgtag('event','conversion',{ send_to, value, currency }). eventName is ignored — Google Ads conversions are always the "conversion" event type. sendTo is REQUIRED.
  • ga4gtag('event', eventName, { value, currency }).
  • metafbq('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:

TriggerPreset
link with tel: hrefPhone Call
link with mailto: hrefContact
link with maps.google / maps.apple / google.com/maps hrefGet Directions
add-to-cart actionAddToCart
cart-checkout actionInitiateCheckout
download-file actionDownload
Form (any submitType)Lead
FallbackClick
// 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"
      }
    }]
  }
}

Modal / drawer show-hide

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 attribute

Components 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.


Where to go from here