---
title: Actions
description: The action prop makes a node interactive; the grammar is six verbs — navigate, back, setState, toggleState, scrollTo, openUrl — plus conditional branches; onEnter runs one after a wait, and hoverAction/leaveAction when the pointer enters or leaves.
status: shipped
url: https://getairframe.com/framewerk/core/actions
---

> Part of the Framewerk docs. Index of every page: /framewerk/llms.txt

# Actions

The `action` prop makes a node interactive; **everything interactive is a string** — there is no embedded JavaScript and no expression compiler, and the action grammar is six verbs.

## Examples

Navigation and state changes:

**Navigate and toggle**

```json
{
  "formatVersion": "1.0",
  "screens": [
    {
      "id": "menu",
      "stateVars": { "oatMilk": { "type": "boolean", "default": "false" } },
      "layout": {
        "_id": "root",
        "type": "column",
        "gap": 12,
        "padding": 16,
        "children": [
          { "_id": "oat", "type": "button", "content": "Oat milk", "action": "toggleState(oatMilk)" },
          { "_id": "order", "type": "button", "variant": "primary", "content": "Order", "action": "navigate(order)" },
          { "_id": "badge", "type": "badge", "content": "Oat milk", "showWhen": "$oatMilk == true" }
        ]
      }
    },
    {
      "id": "order",
      "layout": {
        "_id": "order-root",
        "type": "column",
        "gap": 12,
        "padding": 16,
        "children": [
          { "_id": "order-h", "type": "text", "variant": "heading", "content": "Order" },
          { "_id": "order-back", "type": "button", "content": "Back", "action": "back()" }
        ]
      }
    }
  ],
  "flows": [{ "from": "menu", "to": "order", "trigger": "Order" }]
}
```

A conditional action — first match wins, unconditional branch last:

**Conditional continue**

```json
{
  "formatVersion": "1.0",
  "screens": [
    {
      "id": "checkout",
      "stateVars": { "agreed": { "type": "boolean", "default": "false" } },
      "layout": {
        "_id": "root",
        "type": "column",
        "gap": 16,
        "padding": 24,
        "children": [
          { "_id": "h", "type": "text", "variant": "heading", "content": "Checkout" },
          { "_id": "agree", "type": "checkbox", "label": "I agree", "checked": "$agreed == true", "action": "setState(agreed, true)" },
          { "_id": "go", "type": "button", "variant": "primary", "content": "Continue",
            "action": [
              { "if": "$agreed == true", "do": "navigate(paid)" },
              { "do": "setState(error, must-agree)" }
            ] }
        ]
      }
    },
    {
      "id": "paid",
      "layout": { "_id": "paid-root", "type": "text", "content": "Thanks!" }
    }
  ]
}
```

## Anatomy

Ops are separated by `;` or `|`.

| Action | Effect |
| --- | --- |
| `navigate(screenId)` | Go to a screen. |
| `navigate(screenId, key=value)` | Go to a screen, which arrives with `key` holding `value`. |
| `back()` | Pop the back stack. |
| `setState(key, value)` | Set a state variable. |
| `toggleState(key)` | Flip a boolean. |
| `scrollTo(anchor)` | Jump to the node on the same screen that carries `anchor: "name"`. |
| `openUrl(address)` | Open a web address in a new tab: `https://`, `http://`, `mailto:`, `tel:`, a path, or a `#fragment`. Anything else is refused. |

```json
{ "type": "button", "content": "Order", "action": "navigate(order)" }
{ "type": "button", "content": "Oat milk", "action": "toggleState(oatMilk)" }
{ "type": "button", "content": "Reset", "action": "setState(tab, home); setState(filter, all)" }
```

A key in the `app.` namespace writes [app state](/framewerk/core/state-and-expressions.md#app-state), which a navigating host keeps when it moves to the next screen: `setState(app.lastAdded, cappuccino) | navigate(menu)`. The name must be declared on `project.stateVars`.

### Navigate arguments

A list of eleven products has one product screen. Each card says which product it opens:

```json
{ "type": "pressable", "action": "navigate(product, piece=low-tide-cup)" }
```

The arguments are the state the target screen **arrives with**: over its declared defaults and the persona, under anything pressed after arrival. They belong to the visit, so `back()` returns to a screen with the arguments it was opened with.

- Each key names a `stateVar` the **target** screen declares. An argument it does not declare is a validation warning that lists what it does.
- A value is a literal or `$state.<name>`. `$state.<name>` is read after the press's own `setState`, so `setState(selected, sarah) | navigate(detail, athlete=$state.selected)` passes `sarah`.
- `$self.value` is refused: a press holds no value of its own. Bind the control to a variable and pass that variable.
- A malformed argument refuses the whole `navigate`; a navigate that arrived with two of its three arguments would open the wrong record and say nothing.
- Inside a component, the action reads the instance's prop: `"navigate(product, piece={piece})"`.

An argument is read by one screen for one visit. A value several screens read is [app state](/framewerk/core/state-and-expressions.md#app-state). The host receives the arguments, resolved, as the second parameter of `onNav`.

A legacy colon syntax — `set:key=value` and `toggle:key` — is still parsed and will keep working; prefer the function forms in new specs.

**Conditional actions** take an array of branches, evaluated top to bottom, first match wins. A branch with no `if` is an unconditional fallback — put it last.

Segments matching no verb are collected as **invalid** and reported by the validator rather than silently doing nothing — a typo'd action is a loud failure, not a dead button.

### Actions that run without a press

`action` runs on a click. Three more props take the same action strings and run them at another moment:

| Prop | Runs |
| --- | --- |
| `onEnter: { after, do }` | On a screen, each time the screen is arrived on; on a node, each time the node appears. `after` is a wait in milliseconds (0 when absent); `do` is the action string. Leaving first cancels it. |
| `hoverAction` | When the pointer enters the node. |
| `leaveAction` | When the pointer leaves the node. |

```json
{ "id": "splash", "onEnter": { "after": 800, "do": "navigate(home)" } }
{ "type": "row", "_id": "nav-shop",
  "hoverAction": "setState(shopMenu, open)",
  "leaveAction": "setState(shopMenu, closed)" }
```

A splash that moves on, an onboarding step that advances itself and a toast that leaves on its own are `onEnter`. A menu that opens on hover is state the `showWhen` beneath it already reads.

All three run only where a person plays the prototype. A screenshot, a thumbnail or a filmstrip never starts a timer and never hovers, so a capture of a splash shows the splash. A phone or a tablet has no hover: a node that opens something on hover on a touch device needs a tap that reaches it too (its own `action`, or a pressable around it), and validation names one that has none. An `onEnter` without `do`, or with a wait that is not a number of milliseconds, is an error; its `do` is checked exactly as `action` is.

> **`navigate()` and `back()` need a host.** State actions (`setState`, `toggleState`) work out of the box, but the renderer does not own a screen stack — it reports navigation through `onNav`/`onBack` callbacks and the host decides what happens. A host that passes no-ops gets a screen that renders correctly and never navigates. See the [renderer README](../../../packages/renderer/README.md).

## Stability

The six-verb grammar, navigate's `key=value` arguments, the conditional-array form, `onEnter`, `hoverAction`, `leaveAction`, and the legacy colon syntax are part of the format contract. Adding a verb is additive; removing one, or changing how an existing verb parses, is breaking. Invalid segments are warnings, not errors — treat warnings as failures in CI.
