Prototyper UI

Conditional Rendering

Show and hide elements based on state, repeat context, and compound logic

The visible field on any element controls whether it renders. When visible evaluates to false, the element and all its children are removed from the tree entirely (not just hidden with CSS).

Basic Usage

The simplest condition checks if a state value is truthy:

{
  "type": "Alert",
  "props": { "title": "Welcome back!" },
  "visible": { "$state": "/user/isLoggedIn" }
}

This element renders only when /user/isLoggedIn is truthy (not null, undefined, false, 0, or "").

State Conditions

A state condition reads a value from the global state model:

type StateCondition = {
  $state: string    // JSON Pointer path
  eq?: unknown      // Strict equality
  neq?: unknown     // Strict inequality
  gt?: number       // Greater than
  gte?: number      // Greater than or equal
  lt?: number       // Less than
  lte?: number      // Less than or equal
  not?: boolean     // Invert the result
}

Truthy Check (Default)

When no comparison operator is specified, the condition checks truthiness:

{ "$state": "/user/name" }

Renders when /user/name is a non-empty value.

Equality

{ "$state": "/currentTab", "eq": "settings" }

Renders only when /currentTab is exactly "settings".

Inequality

{ "$state": "/status", "neq": "loading" }

Renders when /status is anything other than "loading".

Numeric Comparisons

{ "$state": "/cart/itemCount", "gt": 0 }
{ "$state": "/user/age", "gte": 18 }
{ "$state": "/inventory/stock", "lt": 10 }
{ "$state": "/form/progress", "lte": 100 }

Negation with not

The not flag inverts the final result of any condition:

{ "$state": "/user/isLoggedIn", "not": true }

This renders when the user is NOT logged in. not is applied after the comparison operator, so you can combine them:

{ "$state": "/status", "eq": "error", "not": true }

Renders when /status is anything other than "error" (equivalent to neq).

Item Conditions (Repeat Context)

Inside a repeat scope, conditions can reference the current item:

type ItemCondition = {
  $item: string     // Field name on the repeat item
  eq?: unknown
  neq?: unknown
  gt?: number
  gte?: number
  lt?: number
  lte?: number
  not?: boolean
}
{
  "type": "Badge",
  "props": { "variant": "default", "label": "Complete" },
  "visible": { "$item": "done", "eq": true }
}

This badge renders only for items where done is true.

Index Conditions (Repeat Context)

Conditions on the current repeat index:

{
  "type": "Separator",
  "props": {},
  "visible": { "$index": true, "gt": 0 }
}

This separator renders for every item except the first (index > 0), creating dividers between items.

Boolean Literals

For explicit always-on or always-off visibility:

{ "visible": true }
{ "visible": false }

true is the default when visible is omitted. Setting visible: false permanently hides an element.

Logical Operators

Implicit AND (Array)

Pass an array of conditions. All must be true:

{
  "visible": [
    { "$state": "/user/isLoggedIn" },
    { "$state": "/user/role", "eq": "admin" }
  ]
}

Renders only when the user is logged in AND is an admin.

Explicit AND

The $and operator is equivalent to an array but more explicit:

{
  "visible": {
    "$and": [
      { "$state": "/user/isLoggedIn" },
      { "$state": "/user/role", "eq": "admin" },
      { "$state": "/feature/adminPanel" }
    ]
  }
}

Explicit OR

The $or operator requires at least one condition to be true:

{
  "visible": {
    "$or": [
      { "$state": "/user/role", "eq": "admin" },
      { "$state": "/user/role", "eq": "moderator" }
    ]
  }
}

Renders for admins OR moderators.

Combining AND and OR

Logical operators can be nested for complex conditions:

{
  "visible": {
    "$and": [
      { "$state": "/user/isLoggedIn" },
      {
        "$or": [
          { "$state": "/user/role", "eq": "admin" },
          { "$state": "/user/permissions/canEdit" }
        ]
      }
    ]
  }
}

Renders when the user is logged in AND (is an admin OR has edit permissions).

Examples

Tab Panel Switching

Show content based on the active tab:

{
  "root": "container",
  "elements": {
    "container": {
      "type": "Card",
      "props": {},
      "children": ["tabs", "profile-panel", "settings-panel", "billing-panel"]
    },
    "tabs": {
      "type": "Tabs",
      "props": {
        "value": { "$bindState": "/activeTab" },
        "items": [
          { "value": "profile", "label": "Profile" },
          { "value": "settings", "label": "Settings" },
          { "value": "billing", "label": "Billing" }
        ]
      }
    },
    "profile-panel": {
      "type": "Text",
      "props": { "content": "Profile content here" },
      "visible": { "$state": "/activeTab", "eq": "profile" }
    },
    "settings-panel": {
      "type": "Text",
      "props": { "content": "Settings content here" },
      "visible": { "$state": "/activeTab", "eq": "settings" }
    },
    "billing-panel": {
      "type": "Text",
      "props": { "content": "Billing content here" },
      "visible": { "$state": "/activeTab", "eq": "billing" }
    }
  },
  "state": { "activeTab": "profile" }
}

Form State Toggle

Show a success message after submission, hide the form:

{
  "type": "Card",
  "props": {},
  "children": ["email-input", "submit-btn"],
  "visible": { "$state": "/submitted", "not": true }
}
{
  "type": "Alert",
  "props": { "title": "Thanks for subscribing!" },
  "visible": { "$state": "/submitted" }
}

Authentication Gate

Show different content for logged-in vs. anonymous users:

{
  "type": "Button",
  "props": { "label": "Sign In" },
  "visible": { "$state": "/user/isLoggedIn", "not": true }
}
{
  "type": "Text",
  "props": { "content": { "$template": "Welcome, ${/user/name}!" } },
  "visible": { "$state": "/user/isLoggedIn" }
}

Repeat Context Filtering

Show a "completed" badge only for done items, and a delete button only for items the user owns:

{
  "type": "Badge",
  "props": { "label": "Done", "variant": "default" },
  "visible": { "$item": "done" }
}
{
  "type": "Button",
  "props": { "label": "Delete", "variant": "destructive", "size": "sm" },
  "visible": {
    "$and": [
      { "$item": "ownerId", "eq": { "$state": "/currentUserId" } },
      { "$item": "done", "not": true }
    ]
  }
}

Dynamic State Comparisons

Comparison operators (gt, gte, lt, lte) accept a ComparisonValue, which can be either a literal number or a { $state: "/path" } reference. This enables comparisons between two state values at runtime.

type ComparisonValue = number | { $state: string }

For example, show a product only when its price is within the user's budget:

{
  "type": "Card",
  "props": { "title": "Premium Plan" },
  "visible": { "$state": "/price", "lte": { "$state": "/budget" } }
}

At evaluation time, both sides resolve to numbers from state. If /price is 49 and /budget is 100, the condition evaluates as 49 <= 100 (true).

Dynamic comparisons work with all numeric operators:

{ "$state": "/score", "gt": { "$state": "/threshold" } }
{ "$state": "/quantity", "gte": { "$state": "/minOrder" } }
{ "$state": "/temperature", "lt": { "$state": "/maxTemp" } }
{ "$state": "/balance", "lte": { "$state": "/creditLimit" } }

Example: Product Filtering by Budget

A product list where items are visible only when their price falls within the user's budget:

{
  "root": "container",
  "elements": {
    "container": {
      "type": "Card",
      "props": {},
      "children": ["budgetSlider", "productList"]
    },
    "budgetSlider": {
      "type": "Slider",
      "props": {
        "label": "Max Budget",
        "min": 0,
        "max": 500,
        "value": { "$bindState": "/budget" }
      }
    },
    "productList": {
      "type": "Card",
      "props": {},
      "children": ["product-a", "product-b", "product-c"]
    },
    "product-a": {
      "type": "Text",
      "props": { "content": "Basic Plan — $29" },
      "visible": { "$state": "/prices/basic", "lte": { "$state": "/budget" } }
    },
    "product-b": {
      "type": "Text",
      "props": { "content": "Pro Plan — $99" },
      "visible": { "$state": "/prices/pro", "lte": { "$state": "/budget" } }
    },
    "product-c": {
      "type": "Text",
      "props": { "content": "Enterprise Plan — $299" },
      "visible": { "$state": "/prices/enterprise", "lte": { "$state": "/budget" } }
    }
  },
  "state": {
    "budget": 200,
    "prices": { "basic": 29, "pro": 99, "enterprise": 299 }
  }
}

As the user moves the budget slider, products dynamically show or hide based on whether their price is within budget.

Item & Index Helpers

When building specs in TypeScript, the visibility helper includes item and index sub-objects for constructing repeat-context conditions:

import { visibility } from "@prototyperai/stream-ui/core"

// Item conditions — reference a field on the current repeat item
visibility.item.when("isActive")          // truthy check
visibility.item.unless("isDeleted")       // falsy check (not: true)
visibility.item.eq("status", "active")    // equality
visibility.item.neq("status", "draft")    // inequality
visibility.item.gt("priority", 5)         // greater than
visibility.item.gte("score", 80)          // greater than or equal
visibility.item.lt("price", { $state: "/maxPrice" })  // less than (dynamic)
visibility.item.lte("quantity", 0)        // less than or equal

// Index conditions — reference the current repeat index
visibility.index.eq(0)    // first item only
visibility.index.neq(0)   // skip first item
visibility.index.gt(0)    // all except first
visibility.index.lt(5)    // first 5 items
visibility.index.gte(2)   // from third item onward
visibility.index.lte(9)   // first 10 items

These helpers return properly typed ItemCondition and IndexCondition objects. Combine them with visibility.and() and visibility.or() for compound conditions:

// Show a separator between items (not before the first)
const separatorVisible = visibility.index.gt(0)

// Show delete button only for owned, incomplete items
const deleteVisible = visibility.and(
  visibility.item.eq("ownerId", currentUserId),
  visibility.item.unless("done"),
)

Visibility vs. $cond

Both visible and $cond use the same condition system, but they serve different purposes:

Featurevisible$cond
ScopeControls whether an element rendersControls the value of a single prop
ResultElement in tree or not$then value or $else value
Use whenShowing/hiding entire sectionsSwitching a variant, label, or style

Use visible to remove elements from the tree. Use $cond in props to change appearance without removing elements:

{
  "type": "Badge",
  "props": {
    "variant": {
      "$cond": { "$state": "/status", "eq": "active" },
      "$then": "default",
      "$else": "secondary"
    },
    "label": { "$state": "/status" }
  }
}

Programmatic Visibility Helpers

When building specs in TypeScript, use the visibility helper for a cleaner API:

import { visibility } from "@prototyperai/stream-ui/core"

const element = {
  type: "Alert",
  props: { title: "Admin Panel" },
  visible: visibility.and(
    visibility.when("/user/isLoggedIn"),
    visibility.eq("/user/role", "admin"),
  ),
}

// Other helpers:
visibility.always          // true
visibility.never           // false
visibility.when("/path")   // truthy check
visibility.unless("/path") // falsy check (not: true)
visibility.eq("/path", v)  // equality
visibility.neq("/path", v) // inequality
visibility.gt("/path", n)  // greater than
visibility.gte("/path", n) // greater than or equal
visibility.lt("/path", n)  // less than
visibility.lte("/path", n) // less than or equal
visibility.and(...)        // all must be true
visibility.or(...)         // at least one must be true

Next Steps

  • Expressions$cond for conditional prop values
  • Actions — Trigger state changes that affect visibility
  • Spec Format — Full spec structure reference

On this page