---
title: State and expressions
description: Each screen declares stateVars as a string map; showWhen/hideWhen take a small expression language over that state, $env and $hover (the pointer inside the nearest hoverGroup).
status: shipped
url: https://getairframe.com/framewerk/core/state-and-expressions
---

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

# State and expressions

**State is a string map.** Each screen declares `stateVars`; every value is a string — `"true"`, `"42"`, `"home"` — and comparisons coerce as needed, which keeps specs JSON-serializable and free of type negotiation between author, validator and renderer.

## Examples

A boolean that gates a badge:

**Conditional visibility**

```json
{
  "formatVersion": "1.0",
  "screens": [
    {
      "id": "menu",
      "stateVars": {
        "oatMilk": { "type": "boolean", "default": "false", "label": "Oat milk" },
        "tab": { "type": "string", "default": "home" }
      },
      "layout": {
        "_id": "root",
        "type": "column",
        "gap": 12,
        "padding": 16,
        "children": [
          { "_id": "h", "type": "text", "variant": "heading", "content": "Coffee Cart" },
          { "_id": "oat", "type": "button", "content": "Toggle oat milk", "action": "toggleState(oatMilk)" },
          { "_id": "badge", "type": "badge", "content": "Oat milk", "showWhen": "$oatMilk == true" },
          { "_id": "desktop-only", "type": "text", "content": "Wide layout", "showWhen": "$env.device == desktop" }
        ]
      }
    }
  ]
}
```

An override keyed on the node's own width:

**Container-relative override**

```json
{
  "formatVersion": "1.0",
  "screens": [
    {
      "id": "cards",
      "layout": {
        "_id": "card",
        "type": "row",
        "gap": 16,
        "children": [
          { "_id": "inner", "type": "text", "content": "Card" }
        ],
        "overrides": [
          { "when": "$self.width < 320", "props": { "type": "column", "gap": 8 } }
        ]
      }
    }
  ]
}
```

## Anatomy

### `stateVars`

```json
{
  "oatMilk": { "type": "boolean", "default": "false", "label": "Oat milk" },
  "tab":     { "type": "string",  "default": "home" }
}
```

`type` is `"string"`, `"boolean"`, or `"number"`. **`default` is always a string**, whatever the type — `"false"`, not `false`. The renderer seeds interactive state from these defaults.

### Expressions

`showWhen` and `hideWhen` take an expression evaluated against current state:

```json
{ "type": "badge", "content": "Oat milk", "showWhen": "$oatMilk == true" }
```

Supported: `==`, `!=`, `>`, `>=`, `<`, `<=`, `in()`, `not in()`, `&&`, `||`, `!`, and parentheses. State values are strings; numeric comparisons coerce to number.

The evaluator normalizes a few forgivable mistakes before parsing — a single `=` becomes `==`, and quotes around single-word values are stripped — because these specs are frequently machine-authored and a silently-inert condition is worse than a lenient parse.

A variable reference must carry its sigil: `$name` for a state variable, `{name}` for a component prop. A bare word is a string literal, so `hasItems == true` compares the text `"hasItems"` — it does not read your state. There is also no way to compare against an empty string; spell "non-empty" as bare truthiness (`$icon`) rather than `$icon != `.

Both mistakes **fail open** — the node renders — because a node that silently vanishes is harder to diagnose than one that silently appears. The safety net is validation, not the render: each produces a warning naming the node and quoting the expression (`unprefixed-variable`, `unparseable-expression`).

**`$hover`** is true while the pointer is inside the nearest node marked `hoverGroup` (`true`, or a name) around or on the node that reads it. A card that shows its actions on hover needs no state variable:

```json
{ "type": "column", "hoverGroup": true, "children": [
  { "type": "row", "showWhen": "$hover", "children": [ … ] } ] }
```

It listens to the pointer entering and leaving only, never its moves, and only in a played prototype. A capture pins it with `$forceState` (`hover` for everything, or `<node id>:hover`). A `$hover` read with no `hoverGroup` around it is never true and is a validation warning. `$hover` is built in; a state variable named `hovered` is a different thing.

**Environment variables** let one spec adapt across devices without duplicating screens:

```json
{ "type": "row", "showWhen": "$env.device == desktop" }
```

Available: `$env.device`, `$env.platform`, `$env.formFactor`, `$env.orientation`, `$env.category`, `$env.deviceClass`, `$env.breakpoint`, `$env.colorMode`. Comparing `$env.device` against `mobile`, `tablet`, or `desktop` matches the preset's *class*, so `$env.device == desktop` is true on `web-lg` without naming the preset. `$env.breakpoint` is the viewport band (`xs` < 480, `sm` < 768, `md` < 1024, `lg` < 1440, `xl`) and ordered comparisons rank on that scale.

**Container-relative conditions.** `$env` answers "how wide is the screen"; `$self.width` and `$self.height` answer "how wide am I", for a component that is 3-up on desktop and 1-up in a sidebar at the same breakpoint. They are readable in `showWhen`, `hideWhen` and `overrides[].when` on the node that declares them; the node needs an `_id`; only nodes that ask are measured, and a node re-renders only when one of its own thresholds is crossed. The authored layout renders until a real size exists. An override on `$self.width` that also sets `width` would oscillate and is a validation warning.

**Scroll variables** make scroll-driven behavior expressible: the screen container publishes `$env.scrollY` (integer px scrolled) and `$env.scrollProgress` (a **0..1 fraction** of the scrollable range — `0.5` is halfway; comparing it to `50` earns a validation warning). Both are `0` at rest.

```json
{ "type": "column", "showWhen": "$env.scrollProgress > 0.5", "animate": "slide-up" }
```

gives reveal-on-scroll, and an override on `$env.scrollY > 40` plus a `transition` gives sticky-header elevation. Re-renders happen only when a compared threshold is crossed, and progress is anchored to the base page height so a revealed node's own height cannot un-trigger it.

### App state

A screen's `stateVars` belong to that screen: a host that navigates starts the next screen from its own defaults. That is right for a tab or an open sheet and wrong for the few values a prototype has to **carry across screens** — what went into the cart, the pickup slot.

Those are declared once, on the project, in the same shape:

```json
{
  "project": {
    "stateVars": {
      "lastAdded": { "type": "string", "default": "flat-white" },
      "cartCount": { "type": "number", "default": "0" }
    }
  }
}
```

They are read as `$app.<name>` in an expression and `{$app.<name>}` in words, and written by the existing verbs with the namespace in the key:

```json
{ "type": "button", "content": "Add to order", "action": "setState(app.lastAdded, cappuccino) | navigate(menu)" }
{ "type": "text", "content": "{$app.lastAdded} added", "showWhen": "$app.cartCount > 0" }
{ "type": "component", "ref": "site-header", "props": { "cartCount": "{$app.cartCount}" } }
```

`{$app.<name>}` resolves in `content`, `label`, `placeholder`, `src`, `viewTransitionName`, and in a component instance's prop value — so one instance shows every value, rather than one instance per value behind `showWhen`.

**One flat map.** An app value lives in the same string map as everything else, under the key `app.<name>`, exactly as a pinned `env.scrollY` does. There is no second store to pass around, and anything that can pin screen state can pin app state by naming that key. From weakest to strongest:

| Layer | Source |
| --- | --- |
| Declared default | `project.stateVars.<name>.default` |
| Persona | `persona.initialState["app.<name>"]` |
| Live value | a `setState(app.<name>, …)` during the session |
| Pin | an explicit `stateValues` entry |

**App state must be declared.** A press that writes an undeclared *screen* variable creates it; a press that writes an undeclared *app* variable does not. Validation warns on a read or a write of a name `project.stateVars` does not declare, and an undeclared read renders as absence. The declaration is what lets a screenshot, a board and a reader of the spec agree on what a screen can be shown with.

**When not to use it.** Anything a single screen owns stays a screen variable. App state does not persist between sessions and no app value is computed from another; both are application logic, which the format does not model.

## Stability

The expression grammar and `$env` / `$self` variable sets are part of the format contract. The `app.` key prefix and the layer order above are part of it too. Adding an operator or variable is additive; removing one, or changing how an existing expression evaluates, is breaking. `sweepExpressions(spec)` reports every suspect expression in a spec on its own, without the rest of the validation output.
