Components

Every component in a JSON-UI spec is referenced by its "type" string in a flat element map. For the full spec format and workflow, see Getting Started.

Each element follows this shape:

"element_id": {
  "type": "ComponentTypeName",
  "props": {
    "prop_name": "prop_value"
  },
  "children": ["child_id"],
  "action": { "handler": "route.name", "method": "POST" },
  "visible": { "path": "/data/status", "operator": "eq", "value": "active" }
}

The sections below document every built-in component: its props table (with JSON types) and a complete element example.


Component Overview

CategoryComponents
LayoutCard, Grid, Tabs, Separator, Modal, Skeleton, Collapsible, FormSection
Data DisplayText, DataTable, Table, DescriptionList, Badge, Avatar, Progress, Breadcrumb, Pagination, StatCard, Image, CalendarCell
FormsForm, Input, Select, Checkbox, CheckboxList, CheckboxGroup, Switch, Button, ButtonGroup, ActionGroup
FeedbackAlert, Toast, EmptyState
NavigationSidebar, Header, PageHeader, NotificationDropdown
ActionActionCard
OnboardingChecklist
Commerce / RegisterTile, TileGrid, SelectionPanel, FilterTabs, QuantityStepper, Numpad
KanbanKanbanBoard, KanbanColumn
ExtensibleRawHtml, Plugin (see Plugins)
Live / Real-timeLiveFragment

Shared Enum Values

Three shared enums cover the weight, status, and scale axes across all components. One word, one meaning: variant is always visual weight, tone is always status color, size is always the sm/md/lg scale.

variant (visual weight of interactive elements) — "primary" | "secondary" | "outline" | "ghost" | "destructive"

tone (status color of stateful display components) — "neutral" | "success" | "warning" | "destructive"

size"sm" | "md" (default) | "lg"

Component-Specific Enum Values

Additional fixed-string enums scoped to individual components; each component section references these by name.

card_appearance"bordered" (default) | "elevated"

column_format"date" | "date_time" | "currency" | "boolean" | "badge" | "image" | "icon"

text_element"p" | "h1" | "h2" | "h3" | "span" | "div" | "section"

input_type"text" | "email" | "password" | "number" | "textarea" | "hidden" | "date" | "time" | "url" | "tel" | "search" | "file"

orientation"horizontal" | "vertical"

icon_position"left" | "right"

sort_direction"asc" | "desc"

form_max_width"default" | "narrow" | "wide"

gap_size"none" | "sm" | "md" (default) | "lg" | "xl" (prop gap; a component-scoped scale, not the shared size)

Component vocabulary migration

The canonical variant/tone/size vocabulary replaced the per-component enums. There are no aliases: retired values on surviving prop names fail at spec parse, and retired prop names (e.g. Card.variant, Badge.variant, confirm.variant) fail catalog validation with an error naming the replacement prop. Old → new mapping:

ComponentOld propOld valueNew propNew value
Button, ActionGroup itemvariantdefaultvariantprimary
Button, ActionGroup itemvariantlinkvariantghost (link style removed)
Button, ActionGroup itemvariantsecondary/outline/ghost/destructivevariantunchanged
Button, Avatar, SegmentedControlsizexssizesm
Button, Avatar, SegmentedControlsizedefaultsizemd
Alert, Toastvariantinfotoneneutral
Alert, Toastvarianterrortonedestructive
Alert, Toastvariantsuccess/warningtoneunchanged
Badgevariantdefault/secondary/outlinetoneneutral
Badgevariantwarning/destructivetoneunchanged
Cardvariantbordered/elevatedappearanceunchanged values
ActionCardvariantdefault/setup/dangertoneneutral/warning/destructive (+ success)
DataTable badge columnrow {"variant": ..}old badge valuesrow {"tone": ..}canonical tone values
MediaCardGridbadge_variant_keyoutline/destructive/defaultbadge_tone_keycanonical tone values
ConfirmDialogvariantdefault/dangertoneneutral/destructive
Notify outcomevariantinfo/error (+success/warning)toneneutral/destructive (+unchanged)

Behavior and visual deltas to expect when migrating:

  • Badge default/secondary/outline all map to tone: "neutral", rendered with one outlined treatment (border border-border text-text) — the old primary-tinted default fill is gone.
  • Alert info was primary-tinted; tone: "neutral" renders on the neutral surface family (bg-surface border-border text-text).
  • Relationship (link) buttons lose the underline-link look — the link variant is removed; use "ghost".
  • ActionCard's neutral left border changed from border-l-primary to border-l-border; a success tone arm is new.
  • CalendarCell dot_colors still accepts raw Tailwind class strings (pre-existing behavior outside the semantic vocabulary; a candidate for the design lint).

Component rename migration (v16.6)

The commerce tile builtin and its associated prop and data attribute were renamed in v16.6 to domain-neutral identifiers. There are no aliases: old type strings and prop names fail spec validation. Migration action:

Renamed in v16.6New surfaceMigration action
Commerce tile builtin (formerly the product-prefixed type string)TileChange "type" in every spec to "Tile"
Tile primary-id prop (formerly the product-prefixed prop name)item_idRename the prop in every Tile element
Tile category data attribute (formerly the product-prefixed data attribute)data-filter-tokensUpdate any custom runtime that reads the old attribute
Tile interaction (on-tile +/- stepper)Tile (tap-to-add)The on-tile quantity stepper markup was replaced in v16.6 (Phase 256): the tile root is now a tap-to-add button (one tap adds one unit). Per-line quantity editing moved to the SelectionPanel.

Layout Components

Card

Container with title, optional description, nested children, and footer.

PropTypeDescription
titlestringCard heading
descriptionstring | nullSecondary text below the title
subtitlestring | nullMuted secondary identifier rendered between title and description (e.g. staff name beneath customer name)
badgestring | nullSmall Badge-styled pill rendered to the right of the title (secondary-tinted chrome) for status indicators, counters, or countdown labels

Children are element IDs listed in the "children" array on the element, not in props.

"user_card": {
  "type": "Card",
  "props": {
    "title": "User Details",
    "description": "Account information"
  },
  "children": ["name_text", "email_text"]
}

Optional subtitle and badge slots add a muted secondary identifier and a Badge-styled pill respectively. Vertical stacking: title → subtitle → description.

"booking_card": {
  "type": "Card",
  "props": {
    "title": "Booking #1",
    "subtitle": "Marco Rossi",
    "description": "Pending email confirmation",
    "badge": "Scade tra 9m"
  },
  "children": []
}

Children semantics: On container components (Card, Form, Grid, etc.) children is an array of element ID strings that reference entries in the spec's flat elements map. The elements themselves are siblings at the top level — not nested.

{
  "$schema": "ferro-json-ui/v2",
  "root": "card_main",
  "elements": {
    "card_main": {
      "type": "Card",
      "props": { "title": "Welcome" },
      "children": ["heading", "form_login"]
    },
    "heading": {
      "type": "Text",
      "props": { "content": "Sign in", "element": "h2" }
    },
    "form_login": {
      "type": "Form",
      "props": {
        "action": { "handler": "auth.login", "method": "POST" },
        "max_width": "narrow"
      },
      "children": ["email_input", "submit_btn"]
    },
    "email_input": {
      "type": "Input",
      "props": { "field": "email", "label": "Email", "input_type": "email" }
    },
    "submit_btn": {
      "type": "Button",
      "props": { "label": "Sign in", "button_type": "submit" }
    }
  }
}

All elements — card_main, heading, form_login, email_input, submit_btn — are siblings in the elements map. The tree structure is expressed purely through children ID references.

Appearance

Card accepts an optional appearance prop controlling chrome and padding.

card_appearance"bordered" (default) | "elevated"

ValueClasses appliedPaddingTypical use
"bordered"border border-border bg-card shadow-sm overflow-visiblep-4Dashboard cards in dense layouts
"elevated"bg-card shadow-md overflow-visible (no border)p-8Auth pages, error pages, standalone marketing cards

appearance defaults to "bordered" when omitted.

"auth_card": {
  "type": "Card",
  "props": {
    "title": "Sign in",
    "appearance": "elevated"
  },
  "children": ["login_form"]
}

Grid

Responsive grid layout for arranging child elements in columns.

PropTypeDescription
columnsnumber | nullNumber of columns (default: 2)
gapgap_size | nullGap between items: "none", "sm", "md", "lg", "xl"
"stats_grid": {
  "type": "Grid",
  "props": {
    "columns": 3,
    "gap": "md"
  },
  "children": ["revenue_stat", "orders_stat", "users_stat"]
}

Visibility

visible is an element-level field that lives on every JSON-UI element. It is not a GridProps prop. When the visibility condition evaluates false against the spec's data payload, the Grid and all of its children are absent from the rendered DOM — the entire subtree is omitted (no hidden attribute, no empty wrapper).

"staff_chips_row": {
  "type": "Grid",
  "props": { "columns": 1, "gap": "sm" },
  "children": ["staff_chip"],
  "visible": { "path": "/has_staff", "operator": "eq", "value": true }
}

Identical semantics apply to every other v2 component — Card, Form, Button, Badge, and all plugin components. The visibility check runs once per element in the walker before component dispatch (ferro-json-ui/src/render/mod.rs element-level visibility check), so there is no per-component scope shifting and no component-specific visibility behavior.

Tabs

Tabbed content with multiple panels.

PropTypeDescription
default_tabstringValue of the initially active tab
tabsarrayTab definitions

Each object in tabs:

FieldTypeDescription
valuestringTab identifier (matches default_tab)
labelstringTab label text
childrenarray of stringsElement IDs shown when the tab is active
"settings_tabs": {
  "type": "Tabs",
  "props": {
    "default_tab": "general",
    "tabs": [
      { "value": "general", "label": "General", "children": ["general_form"] },
      { "value": "security", "label": "Security", "children": ["security_form"] }
    ]
  }
}

Separator

Visual divider between content sections.

PropTypeDescription
orientationorientation | null"horizontal" (default) or "vertical"
"divider": {
  "type": "Separator",
  "props": {}
}

Dialog overlay with title, body children, footer children, and a trigger button label.

PropTypeDescription
idstringHTML id of the <dialog> element; the trigger button references it
titlestringModal heading
descriptionstring | nullModal description text
trigger_labelstring | nullLabel for the button that opens the modal
footerarray | nullElement IDs rendered in the modal footer

Children of the modal body go in the element "children" array. Footer children use the "footer" prop listing element IDs.

"delete_modal": {
  "type": "Modal",
  "props": {
    "id": "delete_modal",
    "title": "Delete Item",
    "description": "This action cannot be undone.",
    "trigger_label": "Delete"
  },
  "children": ["confirm_text"],
  "action": { "handler": "items.destroy", "method": "DELETE" }
}

Skeleton

Loading placeholder with configurable dimensions.

PropTypeDescription
widthstring | nullCSS width (e.g., "100%", "200px")
heightstring | nullCSS height (e.g., "40px")
roundedboolean | nullUse rounded corners
"loading_placeholder": {
  "type": "Skeleton",
  "props": {
    "width": "100%",
    "height": "40px",
    "rounded": true
  }
}

Collapsible

An expandable/collapsible section (<details>/<summary>) with a toggle label.

PropTypeDescription
titlestringLabel for the toggle
expandedboolean | nullInitially open when true (default: false)
"advanced_section": {
  "type": "Collapsible",
  "props": {
    "title": "Advanced Options",
    "expanded": false
  },
  "children": ["timeout_input", "retry_input"]
}

FormSection

Groups form fields under a section heading with an optional description.

PropTypeDescription
titlestringSection heading
descriptionstring | nullSection description
"billing_section": {
  "type": "FormSection",
  "props": {
    "title": "Billing Information",
    "description": "Used for invoice generation."
  },
  "children": ["address_input", "city_input", "postal_input"]
}

Data Display Components

Text

Renders text content with a semantic HTML element.

PropTypeDescription
contentstringText content
elementtext_element | nullHTML element: "p" (default), "h1", "h2", "h3", "span", "div", "section"
"page_heading": {
  "type": "Text",
  "props": {
    "content": "Welcome to the dashboard",
    "element": "h1"
  }
}

Content can use a $template expression to interpolate data:

"greeting": {
  "type": "Text",
  "props": {
    "content": { "$template": "Welcome, {/user/name}!" },
    "element": "h2"
  }
}

DataTable

Data-bound table with column definitions, per-row dropdown actions, mobile card fallback, and empty state. Rows are loaded from the spec's data via data_path.

PropTypeDescription
columnsarrayColumn definitions (see below)
data_pathstringJSON Pointer to the row data array (e.g., "/orders")
row_actionsarray | nullPer-row dropdown actions — {"label", "action", "destructive"?, "visible_if"?} objects
empty_messagestring | nullMessage when no data is present
row_keystring | nullRow field used for {row_key} substitution in action URLs (defaults to id)
row_hrefstring | nullURL pattern for row-click navigation; use {row_key} as placeholder

Each column object:

FieldTypeDescription
keystringData field key in the row object
labelstringColumn header text
formatcolumn_format | nullDisplay format

For format: "badge" the cell value is an object {"tone": .., "label": ..} with a canonical tone value, rendered as a Badge pill. format: "image" reads the cell as an image URL; format: "icon" reads it as a built-in icon name.

"users_table": {
  "type": "DataTable",
  "props": {
    "data_path": "/users",
    "columns": [
      { "key": "name", "label": "Name" },
      { "key": "email", "label": "Email" },
      { "key": "created_at", "label": "Created", "format": "date" }
    ],
    "row_actions": [
      { "label": "Edit", "action": { "handler": "users.edit", "method": "GET" } },
      {
        "label": "Delete",
        "destructive": true,
        "action": {
          "handler": "users.destroy",
          "method": "DELETE",
          "confirm": { "title": "Delete this user?" }
        }
      }
    ],
    "empty_message": "No users found."
  }
}

Table

Lightweight data-bound table. Rows load from the spec's data via data_path — like DataTable, but with plain per-row action buttons instead of the dropdown menu, row-click navigation, and mobile card fallback. Prefer DataTable for entity list pages.

PropTypeDescription
columnsarrayColumn definitions (same structure as DataTable)
data_pathstringJSON Pointer to the row data array (e.g., "/plans")
row_actionsarray | nullPlain Action objects rendered per row
empty_messagestring | nullMessage when no data is present
sortableboolean | nullEnable column sorting
sort_columnstring | nullCurrently sorted column key
sort_directionsort_direction | null"asc" or "desc"
"plans_table": {
  "type": "Table",
  "props": {
    "data_path": "/plans",
    "columns": [
      { "key": "plan", "label": "Plan" },
      { "key": "price", "label": "Price", "format": "currency" }
    ]
  }
}

With handler data:

{
  "plans": [
    { "plan": "Starter", "price": "9.00" },
    { "plan": "Pro", "price": "29.00" }
  ]
}

DescriptionList

Key-value pairs displayed as a description list.

PropTypeDescription
itemsarrayDescription items (see below)
columnsnumber | nullNumber of columns for layout

Each item object:

FieldTypeDescription
labelstringItem label
valuestringItem value
formatcolumn_format | nullDisplay format
"user_info": {
  "type": "DescriptionList",
  "props": {
    "columns": 2,
    "items": [
      { "label": "Name", "value": { "$data": "/user/name" } },
      { "label": "Joined", "value": { "$data": "/user/created_at" }, "format": "date" },
      { "label": "Active", "value": { "$data": "/user/active" }, "format": "boolean" }
    ]
  }
}

Dynamic items via data_path

data_path (optional) takes precedence over items when set. It resolves to a JSON array decoded as Vec<DescriptionItem> ({ "label": string, "value": string, "format"?: string }). Falls back to items when the path is missing.

"document_details": {
  "type": "DescriptionList",
  "props": {
    "columns": 2,
    "data_path": "/document/fields"
  }
}

Handler data: { "document": { "fields": [{ "label": "Author", "value": "Alice" }, { "label": "Created", "value": "2026-05-17", "format": "date" }] } }.

Badge

Small tone-styled status label.

PropTypeDescription
labelstringBadge text
tonetone | nullStatus color (default: "neutral", rendered outlined)
"status_badge": {
  "type": "Badge",
  "props": {
    "label": "Active",
    "tone": "success"
  }
}

Avatar

User avatar with image, fallback initials, and size.

PropTypeDescription
altstringAlt text (required for accessibility)
srcstring | nullImage URL
fallbackstring | nullFallback initials when no image
sizesize | null"sm", "md" (default), "lg"
"user_avatar": {
  "type": "Avatar",
  "props": {
    "alt": "Alice Johnson",
    "src": { "$data": "/user/avatar_url" },
    "fallback": "AJ",
    "size": "lg"
  }
}

Progress

Progress bar with a percentage value.

PropTypeDescription
valuenumberPercentage value (0-100)
maxnumber | nullMaximum value
labelstring | nullLabel text above the bar
"upload_progress": {
  "type": "Progress",
  "props": {
    "value": 75,
    "max": 100,
    "label": "Uploading..."
  }
}

Navigation breadcrumb trail.

PropTypeDescription
itemsarrayBreadcrumb items (see below)

Each item object:

FieldTypeDescription
labelstringBreadcrumb text
urlstring | nullLink URL (omit for the current page)
"breadcrumbs": {
  "type": "Breadcrumb",
  "props": {
    "items": [
      { "label": "Home", "url": "/" },
      { "label": "Users", "url": "/users" },
      { "label": "Edit User" }
    ]
  }
}

Pagination

Page navigation for paginated data.

PropTypeDescription
current_pagenumberCurrent page number
per_pagenumberItems per page
totalnumberTotal item count
base_urlstring | nullBase URL for page links
"users_pagination": {
  "type": "Pagination",
  "props": {
    "current_page": { "$data": "/meta/page" },
    "per_page": 25,
    "total": { "$data": "/meta/total" },
    "base_url": "/users"
  }
}

StatCard

Metric card for dashboards. Displays a label and value, with an optional SSE target for live updates.

PropTypeDescription
labelstringMetric label (e.g., "Total Revenue")
valuestringCurrent metric value (e.g., "€12,345")
tonetone | nullStatus accent on the value and icon (default: "neutral" — no accent)
iconstring | nullIcon name
subtitlestring | nullSecondary text below the value
sse_targetstring | nullSSE event key for live value updates
"revenue_stat": {
  "type": "StatCard",
  "props": {
    "label": "Total Revenue",
    "value": { "$data": "/stats/revenue_formatted" },
    "icon": "currency-euro",
    "subtitle": "This month",
    "sse_target": "revenue_total"
  }
}

When sse_target is set and the server emits a Server-Sent Event with a matching key, the runtime updates the displayed value in place:

event: live-value
data: {"target": "revenue_total", "value": "€13,210"}

Image

Renders an <img> element.

PropTypeDescription
srcstringImage URL
altstringAlt text
widthnumber | nullCSS width in pixels
heightnumber | nullCSS height in pixels
classstring | nullAdditional CSS classes
"hero_image": {
  "type": "Image",
  "props": {
    "src": "/images/hero.jpg",
    "alt": "Dashboard hero",
    "width": 1200,
    "height": 400
  }
}

Dynamic source via data_path

data_path (optional) takes precedence over src when set. It is a JSON Pointer resolved against handler data at render time; the resolved value is used as the <img src> attribute. Falls back to the static src value when the path is missing or resolves to a non-string.

"product_image": {
  "type": "Image",
  "props": {
    "src": "/images/placeholder.jpg",
    "alt": "Product image",
    "data_path": "/product/image_url"
  }
}

Handler data: { "product": { "image_url": "/uploads/product-42.jpg" } } → rendered src is /uploads/product-42.jpg.

CalendarCell

Renders a single day cell in a month grid. Intended for use inside a custom calendar layout; not a standalone page component.

PropTypeDescription
daynumberDay of month (1–31)
is_todayboolean | nullHighlights the cell as today (default: false)
is_current_monthboolean | nullDims the cell when outside the current month (default: false)
event_countnumber | nullEvent indicator dot count (default: 0)
dot_colorsarray | nullPer-event Tailwind color classes (e.g. "bg-blue-500"). When non-empty, colored dots replace plain primary dots.
"day_14": {
  "type": "CalendarCell",
  "props": {
    "day": 14,
    "is_today": true,
    "is_current_month": true,
    "event_count": 3,
    "dot_colors": ["bg-blue-500", "bg-green-500", "bg-red-500"]
  }
}

Form Components

Form

Form container with an action binding. Field components go in the element "children" array.

PropTypeDescription
actionobjectSubmit action — {"handler", "method"} (required)
methodstring | nullHTTP method override ("GET", "POST", "PUT", "PATCH", "DELETE")
max_widthform_max_width | nullMax form width: "default", "narrow", "wide"

The submit action is set in props (props.action), unlike Button and Modal which attach the action on the element's "action" field.

"create_form": {
  "type": "Form",
  "props": {
    "action": { "handler": "users.store", "method": "POST" },
    "max_width": "narrow"
  },
  "children": ["name_input", "email_input", "submit_btn"]
}

Input

Text input field with type, label, validation error, and optional data binding.

PropTypeDescription
fieldstringForm field name
labelstringInput label
input_typeinput_type | nullInput type (default: "text")
placeholderstring | nullPlaceholder text
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
errorstring | nullValidation error message
descriptionstring | nullHelp text below the input
default_valuestring | nullPre-filled static value
data_pathstring | nullJSON Pointer for pre-filling from handler data
stepstring | nullHTML step attribute for number inputs (e.g., "0.01")

data_path is a plain string JSON Pointer (not a $data expression). The renderer reads the value from the spec data at that pointer and pre-fills the field.

"email_input": {
  "type": "Input",
  "props": {
    "field": "email",
    "label": "Email Address",
    "input_type": "email",
    "placeholder": "user@example.com",
    "required": true,
    "description": "Your work email",
    "data_path": "/user/email"
  }
}

Select

Dropdown select field with options and optional data binding.

PropTypeDescription
fieldstringForm field name
labelstringSelect label
optionsarrayOption objects: { "value": string, "label": string }
placeholderstring | nullPlaceholder text
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
errorstring | nullValidation error message
descriptionstring | nullHelp text below the select
default_valuestring | nullPre-selected static value
data_pathstring | nullJSON Pointer for pre-selecting from handler data
"role_select": {
  "type": "Select",
  "props": {
    "field": "role",
    "label": "Role",
    "placeholder": "Select a role",
    "required": true,
    "data_path": "/user/role",
    "options": [
      { "value": "admin", "label": "Administrator" },
      { "value": "editor", "label": "Editor" },
      { "value": "viewer", "label": "Viewer" }
    ]
  }
}

Checkbox

Boolean checkbox field.

PropTypeDescription
fieldstringForm field name
labelstringCheckbox label
descriptionstring | nullHelp text below the checkbox
checkedboolean | nullDefault checked state
data_pathstring | nullJSON Pointer for pre-filling from handler data
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
errorstring | nullValidation error message
"terms_checkbox": {
  "type": "Checkbox",
  "props": {
    "field": "terms",
    "label": "Accept Terms of Service",
    "description": "You must accept to continue.",
    "required": true
  }
}

Switch

State-flip toggle. Use Switch when the semantic is "flip this state" (on/off, open/closed, enabled/disabled) — distinct from Checkbox, which expresses a binary choice within a set of options. The renderer emits role="switch" and aria-checked so browsers and assistive technology recognize the toggle affordance.

PropTypeDescription
fieldstringForm field name
labelstringSwitch label
descriptionstring | nullHelp text below the switch
checkedboolean | nullDefault checked state
data_pathstring | nullJSON Pointer for pre-filling from handler data
requiredboolean | nullMark as required
disabledboolean | nullDisable the field
compactboolean | nullScale the toggle down (scale-75) for use in dense grid layouts
errorstring | nullValidation error message
actionAction | nullWhen present, wraps the switch in a <form> and auto-submits on change
"day_open_switch": {
  "type": "Switch",
  "props": {
    "field": "day_1_is_open",
    "label": "Aperto",
    "data_path": "/schedule/day_1_is_open",
    "compact": true,
    "action": { "handler": "schedule.toggle_day", "method": "POST", "url": "/schedule/toggle" }
  }
}

Substitution: Checkbox styled as switch

For consumers who do not need state-flip semantics and prefer to compose from Checkbox primitives, render a Checkbox and apply the Tailwind utility classes that yield a switch appearance (rounded-full track, translated indicator, etc.). Switch remains the recommended path when the semantic is "flip this state" — its dedicated rendering emits the role="switch" ARIA marker and the visual affordance browsers and assistive technology recognize.

There is no variant: "switch" prop on Checkbox today. The substitution is purely visual via custom class hooks, not an API-level feature.

CheckboxList

A group of checkboxes sharing a single form field name. Each checked option submits as field=value. Supports both static option lists and data-driven options resolved from handler data.

PropTypeDescription
fieldstringForm field name; each selected checkbox submits as field=value
optionsarray | nullStatic option list: [{ "value": string, "label": string }]
options_pathstring | nullJSON Pointer to a data array of { "value", "label" } objects (used when options is empty)
selected_pathstring | nullJSON Pointer to a string[] of pre-selected values
labelstring | nullGroup label
descriptionstring | nullHelp text below the group
disabledboolean | nullDisable all checkboxes
errorstring | nullValidation error message

options_path and selected_path are plain JSON Pointer strings, not $data expressions.

"services_list": {
  "type": "CheckboxList",
  "props": {
    "field": "services",
    "label": "Choose Services",
    "options_path": "/available_services",
    "selected_path": "/user/selected_services"
  }
}

CheckboxGroup

An alias for CheckboxList. Accepts identical props and produces identical HTML output — a <fieldset> with one <input type="checkbox"> per option, each with name="field" for form submission. Use whichever name reads more clearly in a given spec; there is no behavioral difference.

"copy_targets": {
  "type": "CheckboxGroup",
  "props": {
    "field": "copy_to",
    "label": "Copia su",
    "options": [
      { "value": "tue", "label": "Martedì" },
      { "value": "wed", "label": "Mercoledì" },
      { "value": "thu", "label": "Giovedì" }
    ]
  }
}

Each checked option submits as copy_to=<value>. When multiple options are checked, the browser sends repeated copy_to parameters (standard HTML multi-value form semantics).

Substitution: composing from Checkbox primitives

As an alternative, the same array-submit semantics can be composed directly from individual Checkbox elements whose field ends in []. The [] suffix causes the browser to collect all checked values under a single array key:

"copy_tue": {
  "type": "Checkbox",
  "props": { "field": "copy_to[]", "label": "Martedì", "value": "tue" }
},
"copy_wed": {
  "type": "Checkbox",
  "props": { "field": "copy_to[]", "label": "Mercoledì", "value": "wed" }
},
"copy_thu": {
  "type": "Checkbox",
  "props": { "field": "copy_to[]", "label": "Giovedì", "value": "thu" }
}

Each checked input submits as copy_to[]=<value>, which most server frameworks decode as an array under the key copy_to.

When to use CheckboxGroup: data-driven multi-select where the option list comes from handler data (options_path) or is defined once statically. Compact and concise.

When to compose from Checkbox: per-option conditional visibility ("visible" rules), per-option custom layout inside a Grid or FormSection, or per-option data_path binding. The explicit form is more verbose but gives full control over each item's placement and visibility.

Button

Interactive button. Attach the click action on the element's "action" field.

PropTypeDescription
labelstringButton label
variantvariant | nullVisual weight (default: "primary")
sizesize | nullButton size (default: "md")
disabledboolean | nullDisable the button
iconstring | nullIcon name
icon_positionicon_position | null"left" (default) or "right"
button_typestring | nullHTML button type: "button" (default), "submit"
formstring | nullHTML5 form attribute — lets a button rendered outside its target <form> submit it by matching the form's id
disable_on_submitboolean | nullEmits data-disable-on-submit; the runtime disables the button after the first form submission (double-submit guard). Set on the confirm button in register compositions
"save_btn": {
  "type": "Button",
  "props": {
    "label": "Save Changes",
    "variant": "primary",
    "size": "md",
    "icon": "save",
    "icon_position": "left"
  },
  "action": { "handler": "profile.update", "method": "PUT" }
}

ButtonGroup

A horizontal group of buttons rendered together.

PropTypeDescription
buttonsarrayButton definitions (same props as Button, plus "action")
"filter_group": {
  "type": "ButtonGroup",
  "props": {
    "buttons": [
      { "label": "All", "variant": "primary" },
      { "label": "Active", "variant": "outline" },
      { "label": "Archived", "variant": "outline" }
    ]
  }
}

ActionGroup

Renders one ordered action list as inline buttons plus a trailing overflow kebab. The component partitions the list structurally: the first max_inline non-destructive items render as inline buttons (the first is the primary action), any remaining non-destructive items move into the kebab, and every destructive item is forced into the kebab and rendered last regardless of input order. The kebab is omitted entirely when nothing overflows. Non-GET inline actions are wrapped in a <form> automatically; GET actions render as plain links.

PropTypeDescription
itemsarray | {"$data":"/path"}Ordered action items (literal list or a data binding)
menu_idstringRequired — pairs the overflow popover
max_inlinenumber | nullInline non-destructive button cap (default 2)
overflow_labelstring | nullKebab aria-label (default "Azioni")
row_keystring | nullRow identifier for {row_key} substitution in DataTable/Kanban contexts

Each item object:

FieldTypeDescription
labelstringButton / menu item text
actionobjectAction declaration ({ "handler": ..., "method": ... })
destructivebooleantrue forces the item into the kebab, rendered last (default false)
variantvariant | nullButton weight for the inline rendering
iconstring | nullOptional icon name
visible_ifstring | nullRow field gate (fail-closed: an absent or falsy field hides the item)

items accepts a literal array or a {"$data":"/path"} binding; in DataTable/Kanban contexts the bound rows support {row_key} substitution and the visible_if per-row gate.

"row_actions": {
  "type": "ActionGroup",
  "props": {
    "menu_id": "order_actions",
    "max_inline": 2,
    "items": [
      { "label": "View Details", "action": { "handler": "orders.show", "method": "GET" } },
      { "label": "Mark Shipped", "action": { "handler": "orders.ship", "method": "POST" } },
      { "label": "Refund", "action": { "handler": "orders.refund", "method": "POST" } },
      { "label": "Delete", "action": { "handler": "orders.destroy", "method": "DELETE" }, "destructive": true }
    ]
  }
}

In this example "View Details" and "Mark Shipped" render as inline buttons (View first, as the primary action), "Refund" overflows into the kebab (beyond max_inline), and "Delete" is in the kebab and rendered last because it is destructive.


Feedback Components

Alert

Alert message with tone-based styling and optional title.

PropTypeDescription
messagestringAlert message content
tonetone | nullStatus color (default: "neutral")
titlestring | nullAlert title
"trial_warning": {
  "type": "Alert",
  "props": {
    "message": "Your trial expires in 3 days.",
    "tone": "warning",
    "title": "Trial Ending"
  }
}

Toast

Declarative notification rendered as an overlay by the JS runtime. When a Toast element is in the spec, the runtime displays it on page load and dismisses it after the timeout.

PropTypeDescription
messagestringToast message content
tonetone | nullStatus color (default: "neutral")
timeoutnumber | nullSeconds before auto-dismiss (default: 5). 0 with dismissible: true keeps the toast visible until manually closed
dismissibleboolean | nullRender a manual close button (default: true). When false, timeout is clamped to a minimum of 1 second so the toast always auto-dismisses
"save_toast": {
  "type": "Toast",
  "props": {
    "message": "Changes saved successfully.",
    "tone": "success",
    "timeout": 3,
    "dismissible": true
  }
}

EmptyState

Displayed when a list or table has no data. Provides a call-to-action.

PropTypeDescription
titlestringEmpty state heading
descriptionstring | nullSupporting text
action_labelstring | nullCTA button label
iconstring | nullIcon name

Pair with an element "action" for the CTA navigation.

"no_orders": {
  "type": "EmptyState",
  "props": {
    "title": "No orders yet",
    "description": "Create your first order to get started.",
    "action_label": "New Order",
    "icon": "shopping-bag"
  },
  "action": { "handler": "orders.create", "method": "GET" }
}

Sidebar navigation shell with fixed top items, grouped items, and fixed bottom items. Typically used inside the dashboard layout.

PropTypeDescription
fixed_toparray | nullItems pinned at the top (e.g., logo/home)
groupsarray | nullCollapsible navigation groups
fixed_bottomarray | nullItems pinned at the bottom (e.g., settings, logout)

Navigation item object:

FieldTypeDescription
labelstringLink text
hrefstringLink URL
iconstring | nullIcon name
activeboolean | nullMark as current page

Navigation group object:

FieldTypeDescription
labelstringGroup heading
collapsedboolean | nullStart collapsed
itemsarrayNavigation items in this group
"sidebar": {
  "type": "Sidebar",
  "props": {
    "fixed_top": [
      { "label": "Dashboard", "href": "/", "icon": "home", "active": true }
    ],
    "groups": [
      {
        "label": "Management",
        "collapsed": false,
        "items": [
          { "label": "Users", "href": "/users", "icon": "users" },
          { "label": "Orders", "href": "/orders", "icon": "shopping-bag" }
        ]
      }
    ],
    "fixed_bottom": [
      { "label": "Settings", "href": "/settings", "icon": "cog" }
    ]
  }
}

Application header with business name, user info, notification count, and logout link. Typically used inside the dashboard layout.

PropTypeDescription
business_namestringApplication name
notification_countnumber | nullUnread notification count
user_namestring | nullCurrent user's name
user_avatarstring | nullCurrent user's avatar URL
logout_urlstring | nullLogout link URL
"app_header": {
  "type": "Header",
  "props": {
    "business_name": "My App",
    "notification_count": { "$data": "/notifications/unread" },
    "user_name": { "$data": "/auth/user/name" },
    "logout_url": "/logout"
  }
}

Page-level header with a title, optional subtitle, optional breadcrumb, and optional action buttons.

PropTypeDescription
titlestringPage title
breadcrumbarray | nullBreadcrumb items (same shape as Breadcrumb items)
actionsarray | nullElement IDs of action button elements rendered to the right of the title
"page_header": {
  "type": "PageHeader",
  "props": {
    "title": "Orders",
    "breadcrumb": [
      { "label": "Home", "url": "/" },
      { "label": "Orders" }
    ],
    "actions": ["new_order_btn"]
  }
}

actions — lax acceptance

actions accepts any of the following forms, all of which deserialize to an empty or populated list:

Wire valueResult
omittedempty list
nullempty list
"" (empty string)empty list
["btn_id", ...]list of element IDs

Controllers that pass "" or omit the field when there are no actions do not need a special-case branch — all lax forms produce an empty list.

NotificationDropdown

A dropdown list of notification items, typically rendered inside a Header.

PropTypeDescription
notificationsarrayNotification items (see below)
empty_textstring | nullText when list is empty

Each notification object:

FieldTypeDescription
textstringNotification message
iconstring | nullIcon name
timestampstring | nullHuman-readable time string
readboolean | nullWhether the notification has been read
action_urlstring | nullURL to navigate to on click
"notifications": {
  "type": "NotificationDropdown",
  "props": {
    "empty_text": "No new notifications",
    "notifications": [
      {
        "icon": "bell",
        "text": "New order received",
        "timestamp": "5 minutes ago",
        "read": false,
        "action_url": "/orders/123"
      },
      {
        "text": "Payment processed",
        "timestamp": "1 hour ago",
        "read": true
      }
    ]
  }
}

Action Components

ActionCard

A card that acts as a clickable action item.

PropTypeDescription
titlestringCard heading
descriptionstring | nullSupporting text
iconstring | nullIcon name
tonetone | nullLeft-border status color (default: "neutral")
"create_product": {
  "type": "ActionCard",
  "props": {
    "title": "Add Product",
    "description": "Create a new product listing.",
    "icon": "plus",
    "tone": "neutral"
  },
  "action": { "handler": "products.create", "method": "GET" }
}

Onboarding Components

Checklist

Step-by-step onboarding checklist with optional server-side state persistence.

PropTypeDescription
titlestringChecklist heading
itemsarrayChecklist items (see below)
dismissibleboolean | nullAllow dismissal (default: true)
dismiss_labelstring | nullCustom dismiss button label
data_keystring | nullServer-side state persistence key

Each item object:

FieldTypeDescription
labelstringStep description
checkedboolean | nullWhether this step is complete
hrefstring | nullLink to complete the step
"setup_checklist": {
  "type": "Checklist",
  "props": {
    "title": "Get Started",
    "dismissible": true,
    "dismiss_label": "Done",
    "data_key": "onboarding_checklist",
    "items": [
      { "label": "Create your account", "checked": true },
      { "label": "Set up billing", "checked": false, "href": "/billing" },
      { "label": "Invite your team", "checked": false, "href": "/team/invite" }
    ]
  }
}

Commerce Components

Tile

Touch-first tap-to-add tile. The whole tile is a single tap surface — a <button> carrying data-qty-inc — and one tap adds one unit to the tile's hidden form input via the JS runtime. There are no on-tile +/− steppers and no on-tile quantity display: per-line quantity editing lives in the SelectionPanel. Optional image area, tone-accent border, and stock badge compose the visual.

PropTypeDescription
item_idstringItem identifier
namestringItem name — also emitted as data-filter-text on the tile root for client-side search and as the SelectionPanel line name
pricestringFormatted display price (e.g., "€29.00")
fieldstringForm field name the selected quantity is written to
default_quantitynumber | nullInitial quantity (default: 0)
categoriesstring[]Category memberships, emitted as a space-separated data-filter-tokens attribute (spaces in a name normalize to hyphens) for filter-tab matching. Default: [] — an untagged tile is visible under the All tab and hidden under any specific category tab
image_urlstring | nullItem image, lazy-loaded at the top of the tile; absent renders a text-only tile
colortone | nullAccent tone for the tile border (shared tone enum); absent or "neutral" renders the default border
stock_badgestring | nullBadge-styled chip text (e.g. "Low", "Out")
price_centsnumber | nullMachine-readable unit price in integer cents, emitted as data-unit-price on the tile root. The SelectionPanel running total reads this attribute (missing is treated as 0 cents); expected to agree with price
"tile": {
  "type": "Tile",
  "props": {
    "item_id": { "$data": "/product/id" },
    "name": { "$data": "/product/name" },
    "price": { "$data": "/product/price_formatted" },
    "price_cents": { "$data": "/product/price_cents" },
    "field": "quantities[1]"
  }
}

Place Tile elements inside a Form — the quantity value submits with the surrounding form, and the paired SelectionPanel resolves the tile's hidden input through that form's id.


TileGrid

Responsive touch-first tile grid. Iterates a data array via the $each directive, rendering one Tile child per row. Optional integrated category strip and client-side text search filter tiles without a round-trip. One tap on a tile adds one unit to the tile's hidden form input; ALL quantity editing (increase, decrease, remove) happens in the paired SelectionPanel.

PropTypeDescription
data_pathstringJSON pointer to the items array iterated via $each; emitted as the loop context for Tile children
form_idstringHTML id of the Form element owning this grid's hidden inputs; emitted as data-selection-form
categories_pathstring | nullJSON pointer to a string array for the integrated category strip; absent renders no strip
columnsnumber | nullBase viewport column count (default: 2)
searchboolean | nullEnable client-side text search input
search_placeholderstring | nullPlaceholder for the search input (default: "Search"); ignored when search is absent or false
all_labelstring | null"Show all" tab label for the integrated strip (default: "All"); ignored when categories_path is absent
"tiles": {
  "type": "TileGrid",
  "props": {
    "data_path": "/data/products",
    "form_id": "sale_form",
    "search": true,
    "columns": 3
  },
  "children": ["tile_tmpl"]
}

Place inside a Form whose HTML id equals form_id — that Form is the common ancestor of both the TileGrid and its paired SelectionPanel. One tap on a tile adds one unit; ALL quantity editing happens in the SelectionPanel. The grid emits data-selection-form so the JS runtime can pair them. Requires fill_viewport: true at the spec level (see Layouts → fill_viewport).


SelectionPanel

Live client-side view of the form state. As tiles are tapped, line items appear and update in the panel — each with a per-line QuantityStepper and a remove control — and the panel computes a client-side integer-cents running total from data-unit-price on each tile. When nothing is selected an empty-state placeholder appears. A confirm action slot (the panel's children) holds the single confirm Button that submits the parent Form.

PropTypeDescription
form_idstringMust match the paired TileGrid form_id; emitted as data-selection-form
empty_messagestring | nullPlaceholder text shown when the panel has no line items
currencystring | nullCurrency symbol (e.g. "€") prepended to the integer-cents running total; no locale tables
total_labelstring | nullRunning-total row label (default: "Total")
"cart": {
  "type": "SelectionPanel",
  "props": {
    "form_id": "sale_form",
    "currency": "€",
    "empty_message": "No items selected"
  },
  "children": ["confirm_btn"]
}

form_id MUST match the paired TileGrid. The panel is not a second source of truth — the hidden inputs in the Form are. Put the confirm Button (with disable_on_submit: true) in the children slot: setting disable_on_submit: true emits data-disable-on-submit, which disables non-quantity buttons on form submit and prevents double-submission. For idempotent confirm handlers, combine this with the framework::write idempotency hook.


FilterTabs

Standalone client-side show/hide filter over sibling tiles by filter token. Each tab is a ≥44 px touch target; tapping a tab hides tiles whose data-filter-tokens attribute does not contain the tab's token. A zero-prop instance renders an All-only strip.

PropTypeDescription
itemsstring[]Category labels rendered as filter tabs; may be $data-bound (default: [])
all_labelstring | null"Show all" tab label (default: "All")
"category_tabs": {
  "type": "FilterTabs",
  "props": {
    "items": { "$data": "/data/categories" }
  }
}

Filters tiles tagged with matching data-filter-tokens within the same data-filter-scope. The TileGrid can render an integrated strip instead via categories_path — a standalone FilterTabs is the author-composable alternative for layouts where the filter strip sits outside the grid wrapper.


QuantityStepper

Reusable +/− numeric stepper that drives a named hidden input. Usable in SelectionPanel selection lines and in standalone forms. The stepper emits data-qty-inc and data-qty-dec attributes; the JS runtime increments or decrements the named input and updates any bound data-qty-display elements.

PropTypeDescription
fieldstringName of the hidden input this stepper drives via data-qty-inc/data-qty-dec
minnumber | nullLower bound (default: 0)
maxnumber | nullUpper bound; unbounded when absent
stepnumber | nullIncrement size (default: 1)
"qty_stepper": {
  "type": "QuantityStepper",
  "props": {
    "field": "qty_1",
    "min": 0,
    "step": 1
  }
}

Drives the named hidden input via data-qty-inc/data-qty-dec; place inside the same Form as the input it edits. The SelectionPanel uses this mechanism for per-line quantity editing in register compositions.


Numpad

Custom tap-surface numeric keypad (≥56 px keys) that writes to a target field. Never renders a native <input> — the software keyboard is never triggered. The JS runtime handles digit accumulation, backspace, and clear, writing the result to the input named by target_field via data-numpad-target.

PropTypeDescription
target_fieldstringName of the input this numpad writes into; emitted as data-numpad-target
mode"quantity" | "price"Entry mode: "quantity" = integer entry; "price" = two-decimal-place monetary entry (default: "quantity")
"keypad": {
  "type": "Numpad",
  "props": {
    "target_field": "amount",
    "mode": "price"
  }
}

Author-composable — NOT part of the v1 register template; add it where numeric entry (amount or quantity) is needed outside the standard TileGrid tap model. Writes to the input named by target_field via data-numpad-target.


Kanban Components

KanbanBoard

Kanban board with fixed lanes. On mobile, lanes switch to tabs.

A kanban is fixed lanes plus items sorted into them by a status field. columns is structure (lane id + title) and is always rendered — an empty lane still shows its header and a zero count. Card content is data-bound: items_path resolves a flat array of entity objects, each bucketed into the lane whose id equals the item's group_by value, then rendered as a card via the card_* / row_* bindings. This is the same prescribed-card + field-key convention used by DataTable and MediaCardGrid.

PropTypeDescription
columnsarray | nullLane structure — KanbanColumnProps objects (id + title). Always rendered.
items_pathstring | nullJSON Pointer to a flat array of entity objects to bucket into lanes.
group_bystring | nullField on each item selecting its lane: column.id == item[group_by].
card_title_keystring | nullItem field whose value becomes the card title.
card_description_keystring | nullItem field whose value becomes the card subtitle.
row_actionsarray | nullPer-card dropdown actions. {row_key} / {id} interpolate from the item.
row_keystring | nullItem field used for {row_key} substitution in action URLs (defaults to id).
mobile_default_columnstring | nullLane id selected by default on mobile tab view.
empty_labelstring | nullPlaceholder text shown inside empty lanes.
"order_board": {
  "type": "KanbanBoard",
  "props": {
    "columns": [
      { "id": "pending",    "title": "Pending" },
      { "id": "processing", "title": "Processing" },
      { "id": "done",       "title": "Done" }
    ],
    "items_path": "/data/order",
    "group_by": "status",
    "card_title_key": "name",
    "card_description_key": "total"
  }
}

Handler data — a flat array; the renderer buckets by status, so handlers need no per-lane grouping:

{
  "data": {
    "order": [
      { "id": 1, "name": "#1", "total": "€ 16,00", "status": "pending" },
      { "id": 2, "name": "#2", "total": "€ 40,00", "status": "done" }
    ]
  }
}

For fully-custom card structure (badges, nested elements) rather than the prescribed title/description card, template the cards with the $each directive inside a fixed KanbanColumn instead.

KanbanColumn

A single lane definition inside KanbanBoard.columns — a column object, not a standalone element type.

FieldTypeDescription
idstringLane key matched against each item's group_by value
titlestringLane heading
countnumber | nullLane count badge (static specs only)
childrenarray | nullElement IDs rendered inside the lane (static specs only)
{
  "id": "pending",
  "title": "Pending",
  "children": ["pending_card_template"]
}

count and children are honored only when the board sets neither items_path nor group_by; in the data-bound path the renderer computes counts and renders cards from items_path. See $each inside a KanbanColumn for custom card templating.


Extensible Components

RawHtml

Server-injected HTML island for narrow HTML-fragment use cases: status pills, badge decorations, link wrappers, and similar one-off markup that does not warrant a first-class plugin.

PropTypeDescription
htmlstringServer-constructed HTML emitted verbatim into the response
"status_pill": {
  "type": "RawHtml",
  "props": {
    "html": "<span class=\"pill pill-green\">Active</span>"
  }
}

Trust boundary. html is emitted verbatim with no sanitization. The consumer is responsible for ensuring the value is safe before embedding it in the spec. For untrusted input (e.g., user-supplied content), run it through a sanitizer such as ammonia in the handler before assigning it to html. This mirrors the discipline required by RichTextEditor.

For richer widgets that are interactive, need asset injection (CSS/JS bundles), or are reused across multiple pages, use the first-class plugin system instead — see plugins.md.

For plugin components (third-party or custom types not in the built-in catalog), see Plugins.


StreamText

Connects to a server-sent-events endpoint and renders token-by-token output as plain text. Tokens are appended as text nodes — no HTML interpretation.

PropTypeDescription
sse_urlstringURL of the SSE endpoint that streams tokens
placeholderstring?Text shown inside the content area before the first token arrives
loading_textstring?Status indicator shown while the stream is open
"response_area": {
  "type": "StreamText",
  "props": {
    "sse_url": "/ai/generate",
    "placeholder": "Response will appear here…",
    "loading_text": "Generating…"
  }
}

Server contract. The SSE endpoint must emit event: done when the stream is complete:

#![allow(unused)]
fn main() {
tx.send(SseEvent::new().event("done").data("")).await.ok();
}

Without event: done, the browser's EventSource auto-reconnects after the connection closes, causing the component to re-fetch the endpoint in a loop.

Security. Tokens are appended as plain text nodes — innerHTML is never called. Streamed content cannot inject HTML or execute scripts regardless of its content.


Live / Real-time Components

LiveFragment

Binds a child template to a ferro-projection per-key snapshot and re-renders it in place when the projection emits a delta — server-authoritatively, without a page reload or client-side state.

PropTypeDescription
projectionstringferro-projection NAME (the Projection::NAME const)
keystringPer-key channel selector; combined with projection to form the subscription channel
templateobjectChild JSON-UI spec rendered against the snapshot as its data scope
"live_stock": {
  "type": "LiveFragment",
  "props": {
    "projection": "inventory",
    "key": "warehouse-a",
    "template": {
      "$schema": "ferro-json-ui/v2",
      "root": "count",
      "elements": {
        "count": { "type": "Text", "props": { "content": { "$data": "/count" } } }
      }
    }
  }
}

The "inventory" / "warehouse-a" values above are sample identifiers for illustration.

When no snapshot exists for the key at first paint, the container renders empty (the child template receives {} as data). On each server delta the client runtime swaps the container's innerHTML without a page reload.

One binding pattern is supported: a single per-key snapshot per container. There is no list or collection reconciliation — the whole container HTML is replaced on each delta. For the client subscription details see Runtime Primitives.


Inline view/edit pattern

An inline view/edit page is built from a Form element whose children include both read-only display items and editable inputs, each toggled by a visible condition on a query parameter.

This pattern requires no Rust code to distinguish view and edit modes — the spec handles it entirely through visible rules on query.mode.

{
  "$schema": "ferro-json-ui/v2",
  "title": "Profile",
  "layout": "dashboard",
  "root": "profile_card",
  "elements": {
    "profile_card": {
      "type": "Card",
      "props": { "title": "Profile" },
      "children": ["edit_btn", "profile_form"]
    },
    "edit_btn": {
      "type": "Button",
      "props": { "label": "Edit", "variant": "outline" },
      "action": { "url": "?mode=edit" },
      "visible": { "ne": ["query.mode", "edit"] }
    },
    "profile_form": {
      "type": "Form",
      "props": {
        "action": { "handler": "profile.update", "method": "POST" },
        "max_width": "narrow"
      },
      "children": [
        "name_view", "name_edit",
        "email_view", "email_edit",
        "save_btn"
      ]
    },
    "name_view": {
      "type": "DescriptionList",
      "props": {
        "items": [{ "label": "Name", "value": { "$data": "/user/name" } }]
      },
      "visible": { "ne": ["query.mode", "edit"] }
    },
    "name_edit": {
      "type": "Input",
      "props": {
        "field": "name",
        "label": "Name",
        "data_path": "/user/name"
      },
      "visible": { "eq": ["query.mode", "edit"] }
    },
    "email_view": {
      "type": "DescriptionList",
      "props": {
        "items": [{ "label": "Email", "value": { "$data": "/user/email" } }]
      },
      "visible": { "ne": ["query.mode", "edit"] }
    },
    "email_edit": {
      "type": "Input",
      "props": {
        "field": "email",
        "label": "Email Address",
        "input_type": "email",
        "data_path": "/user/email"
      },
      "visible": { "eq": ["query.mode", "edit"] }
    },
    "save_btn": {
      "type": "Button",
      "props": { "label": "Save", "button_type": "submit" },
      "visible": { "eq": ["query.mode", "edit"] }
    }
  }
}

The visible condition { "eq": ["query.mode", "edit"] } shows the element only when ?mode=edit is present in the URL. The inverse { "ne": ["query.mode", "edit"] } shows the element in all other cases (view mode). See Data Binding & Visibility for the full visible condition reference.