Components

JSON-UI includes 26 built-in component types organized into six groups. Every component serializes to JSON with a "type" discriminant and is wrapped in a ComponentNode that adds a unique key, an optional action binding, and optional visibility rules.

Component Overview

CategoryComponents
LayoutCard, Tabs, Separator, Modal, Skeleton
Data DisplayTable, DescriptionList, Badge, Avatar, Text, Progress, Breadcrumb, Pagination, StatCard
FormsForm, Input, Select, Checkbox, Switch, Button
FeedbackAlert, Toast
NavigationSidebar, Header, NotificationDropdown
OnboardingChecklist
ExtensiblePlugin

ComponentNode

Every component is wrapped in a ComponentNode:

#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, CardProps};

ComponentNode {
    key: "my-card".to_string(),          // unique identifier on the page
    component: Component::Card(CardProps { /* ... */ }),
    action: None,                         // optional Action binding
    visibility: None,                     // optional Visibility condition
}
}

Convenience constructors are available for common components:

#![allow(unused)]
fn main() {
// Equivalent to the struct literal above, but more concise
let node = ComponentNode::card("my-card", CardProps {
    title: "Hello".to_string(),
    description: None,
    children: vec![],
    footer: vec![],
});
}

Shared Types

Size

Controls sizing for Button, Avatar, and other components.

ValueSerialized
Size::Xs"xs"
Size::Sm"sm"
Size::Default"default"
Size::Lg"lg"

ButtonVariant

Visual styles for the Button component (aligned to shadcn/ui).

ValueSerializedUse Case
ButtonVariant::Default"default"Primary actions
ButtonVariant::Secondary"secondary"Secondary actions
ButtonVariant::Destructive"destructive"Delete, remove
ButtonVariant::Outline"outline"Bordered style
ButtonVariant::Ghost"ghost"Minimal style
ButtonVariant::Link"link"Link appearance

AlertVariant

Visual styles for Alert and Toast components.

ValueSerialized
AlertVariant::Info"info"
AlertVariant::Success"success"
AlertVariant::Warning"warning"
AlertVariant::Error"error"

BadgeVariant

Visual styles for the Badge component (aligned to shadcn/ui).

ValueSerialized
BadgeVariant::Default"default"
BadgeVariant::Secondary"secondary"
BadgeVariant::Destructive"destructive"
BadgeVariant::Outline"outline"

ColumnFormat

Display format for Table columns and DescriptionList items.

ValueSerialized
ColumnFormat::Date"date"
ColumnFormat::DateTime"date_time"
ColumnFormat::Currency"currency"
ColumnFormat::Boolean"boolean"

TextElement

Semantic HTML element for the Text component.

ValueSerializedHTML
TextElement::P"p"<p>
TextElement::H1"h1"<h1>
TextElement::H2"h2"<h2>
TextElement::H3"h3"<h3>
TextElement::Span"span"<span>
TextElement::Div"div"<div>
TextElement::Section"section"<section>

ToastVariant

Visual styles for the Toast component. Mirrors AlertVariant.

ValueSerialized
ToastVariant::Info"info"
ToastVariant::Success"success"
ToastVariant::Warning"warning"
ToastVariant::Error"error"

Layout Components

Card

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

PropTypeRequiredDefaultDescription
titleStringYes-Card title
descriptionOption<String>NoNoneDescription below the title
childrenVec<ComponentNode>No[]Nested components in the card body
footerVec<ComponentNode>No[]Components in the card footer
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, CardProps, ButtonProps, ButtonVariant, Size};

let node = ComponentNode::card("user-card", CardProps {
    title: "User Details".to_string(),
    description: Some("Account information".to_string()),
    children: vec![
        ComponentNode::button("edit-btn", ButtonProps {
            label: "Edit".to_string(),
            variant: ButtonVariant::Outline,
            size: Size::Default,
            disabled: None,
            icon: None,
            icon_position: None,
        }),
    ],
    footer: vec![],
});
}

JSON output:

{
  "key": "user-card",
  "type": "Card",
  "title": "User Details",
  "description": "Account information",
  "children": [
    { "key": "edit-btn", "type": "Button", "label": "Edit", "variant": "outline", "size": "default" }
  ]
}

Tabs

Tabbed content with multiple panels. Each tab contains its own set of child components.

PropTypeRequiredDefaultDescription
default_tabStringYes-Value of the initially active tab
tabsVec<Tab>Yes-Tab definitions

Tab defines a tab panel:

FieldTypeRequiredDescription
valueStringYesTab identifier (matches default_tab)
labelStringYesTab label text
childrenVec<ComponentNode>NoComponents displayed when the tab is active
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, TabsProps, Tab};

ComponentNode {
    key: "settings-tabs".to_string(),
    component: Component::Tabs(TabsProps {
        default_tab: "general".to_string(),
        tabs: vec![
            Tab {
                value: "general".to_string(),
                label: "General".to_string(),
                children: vec![/* ... */],
            },
            Tab {
                value: "security".to_string(),
                label: "Security".to_string(),
                children: vec![/* ... */],
            },
        ],
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "settings-tabs",
  "type": "Tabs",
  "default_tab": "general",
  "tabs": [
    { "value": "general", "label": "General", "children": [] },
    { "value": "security", "label": "Security", "children": [] }
  ]
}

Separator

Visual divider between content sections.

PropTypeRequiredDefaultDescription
orientationOption<Orientation>NoHorizontalDirection: horizontal or vertical
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, SeparatorProps};

ComponentNode {
    key: "divider".to_string(),
    component: Component::Separator(SeparatorProps {
        orientation: None, // defaults to horizontal
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "divider", "type": "Separator" }

Dialog overlay with title, content, footer, and trigger button.

PropTypeRequiredDefaultDescription
titleStringYes-Modal title
descriptionOption<String>NoNoneModal description
childrenVec<ComponentNode>No[]Content components inside the modal body
footerVec<ComponentNode>No[]Components in the modal footer
trigger_labelOption<String>NoNoneLabel for the trigger button
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, ModalProps, ButtonProps, ButtonVariant, Size, Action};

ComponentNode {
    key: "delete-modal".to_string(),
    component: Component::Modal(ModalProps {
        title: "Delete Item".to_string(),
        description: Some("This action cannot be undone.".to_string()),
        children: vec![],
        footer: vec![
            ComponentNode {
                key: "delete-btn".to_string(),
                component: Component::Button(ButtonProps {
                    label: "Delete".to_string(),
                    variant: ButtonVariant::Destructive,
                    size: Size::Default,
                    disabled: None,
                    icon: None,
                    icon_position: None,
                }),
                action: Some(Action::delete("items.destroy").confirm_danger("Confirm deletion")),
                visibility: None,
            },
        ],
        trigger_label: Some("Delete".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "delete-modal",
  "type": "Modal",
  "title": "Delete Item",
  "description": "This action cannot be undone.",
  "trigger_label": "Delete",
  "children": [],
  "footer": [{ "key": "delete-btn", "type": "Button", "label": "Delete", "variant": "destructive", "size": "default" }]
}

Skeleton

Loading placeholder with configurable dimensions for content that is still loading.

PropTypeRequiredDefaultDescription
widthOption<String>NoNoneCSS width (e.g., "100%", "200px")
heightOption<String>NoNoneCSS height (e.g., "40px")
roundedOption<bool>NoNoneWhether to use rounded corners
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, SkeletonProps};

ComponentNode {
    key: "loading-placeholder".to_string(),
    component: Component::Skeleton(SkeletonProps {
        width: Some("100%".to_string()),
        height: Some("40px".to_string()),
        rounded: Some(true),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "loading-placeholder", "type": "Skeleton", "width": "100%", "height": "40px", "rounded": true }

Data Display Components

Table

Data table with column definitions, row actions, and sorting support. Rows are loaded from handler data via data_path.

PropTypeRequiredDefaultDescription
columnsVec<Column>Yes-Column definitions
data_pathStringYes-Path to the row data array (e.g., "/data/users")
row_actionsOption<Vec<Action>>NoNoneActions available per row
empty_messageOption<String>NoNoneMessage when no data
sortableOption<bool>NoNoneEnable column sorting
sort_columnOption<String>NoNoneCurrently sorted column key
sort_directionOption<SortDirection>NoNoneSort direction: asc or desc

Column defines a table column:

FieldTypeRequiredDescription
keyStringYesData field key matching the row object
labelStringYesColumn header text
formatOption<ColumnFormat>NoDisplay format (Date, DateTime, Currency, Boolean)
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, TableProps, Column, ColumnFormat, Action};

let node = ComponentNode::table("users-table", TableProps {
    columns: vec![
        Column { key: "name".to_string(), label: "Name".to_string(), format: None },
        Column { key: "email".to_string(), label: "Email".to_string(), format: None },
        Column {
            key: "created_at".to_string(),
            label: "Created".to_string(),
            format: Some(ColumnFormat::Date),
        },
    ],
    data_path: "/data/users".to_string(),
    row_actions: Some(vec![
        Action::get("users.edit"),
        Action::delete("users.destroy").confirm_danger("Delete this user?"),
    ]),
    empty_message: Some("No users found.".to_string()),
    sortable: Some(true),
    sort_column: None,
    sort_direction: None,
});
}

JSON output:

{
  "key": "users-table",
  "type": "Table",
  "data_path": "/data/users",
  "columns": [
    { "key": "name", "label": "Name" },
    { "key": "email", "label": "Email" },
    { "key": "created_at", "label": "Created", "format": "date" }
  ],
  "sortable": true
}

DescriptionList

Key-value pairs displayed as a description list. Reuses ColumnFormat for value formatting.

PropTypeRequiredDefaultDescription
itemsVec<DescriptionItem>Yes-Key-value items
columnsOption<u8>NoNoneNumber of columns for layout

DescriptionItem defines a key-value pair:

FieldTypeRequiredDescription
labelStringYesItem label
valueStringYesItem value
formatOption<ColumnFormat>NoDisplay format
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, DescriptionListProps, DescriptionItem, ColumnFormat};

ComponentNode {
    key: "user-info".to_string(),
    component: Component::DescriptionList(DescriptionListProps {
        items: vec![
            DescriptionItem { label: "Name".to_string(), value: "Alice Johnson".to_string(), format: None },
            DescriptionItem {
                label: "Joined".to_string(),
                value: "2026-01-15".to_string(),
                format: Some(ColumnFormat::Date),
            },
            DescriptionItem {
                label: "Active".to_string(),
                value: "true".to_string(),
                format: Some(ColumnFormat::Boolean),
            },
        ],
        columns: Some(2),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "user-info",
  "type": "DescriptionList",
  "columns": 2,
  "items": [
    { "label": "Name", "value": "Alice Johnson" },
    { "label": "Joined", "value": "2026-01-15", "format": "date" },
    { "label": "Active", "value": "true", "format": "boolean" }
  ]
}

DetailForm

Split-mode detail page with inline edit. Renders the same description-list-style scaffold in two modes — View and Edit — driven by a URL query parameter.

When to use. Use DetailForm instead of a pair of DescriptionList (for viewing) + Form (for editing) when you want the view and edit states to share a single structural container, so the user sees the same layout whether they are reading or editing. The mode toggle is URL-driven (?mode=edit); there is no client-side JavaScript state.

PropTypeRequiredDefaultDescription
modeEditModeNoEditMode::ViewWhich mode to render. Typically derived from the URL via EditMode::from_query(req.query("mode").as_deref())
actionActionYes-Form submit target. Resolver populates action.url from action.handler
fieldsVec<DetailField>Yes-The rows
edit_urlStringYes-Href for the "Modifica" link in View mode. Emitted verbatim after html_escape; not resolved by the route registry
cancel_urlStringYes-Href for the "Annulla" link in Edit mode. Emitted verbatim after html_escape; not resolved by the route registry
edit_labelOption<String>No"Modifica"Override for the default "Modifica" label
save_labelOption<String>No"Salva"Override for the default "Salva" label
cancel_labelOption<String>No"Annulla"Override for the default "Annulla" label
methodOption<HttpMethod>No-HTTP method override (else uses action.method); PUT/PATCH/DELETE auto-emit <input type="hidden" name="_method"> spoofing

DetailField defines one row:

FieldTypeRequiredDescription
labelStringYesDescription term shown in both modes as the field label
valueStringYesDisplay string shown in View mode (plain text, html-escaped at render)
inputComponentNodeYesComponent rendered in Edit mode in place of value (typically Input, Select, Textarea, Switch, Checkbox, or a plugin)

EditMode controls which mode to render:

VariantJSONDescription
View"view"Read-only display with a "Modifica" link (default)
Edit"edit"Inline-edit form with "Salva" / "Annulla" actions
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, DetailForm, DetailFormProps, DetailField,
            EditMode, InputProps, InputType, Action, HttpMethod};

let mode = EditMode::from_query(req.query("mode").as_deref());

let node = ComponentNode::detail_form(
    "user-detail",
    DetailFormProps {
        mode,
        action: Action::new("users.update"),
        fields: vec![
            DetailField::new(
                "Name",
                user.name.clone(),
                // Option A: input label must be "" — the <dt> provides the visible label.
                ComponentNode::input("name", InputProps {
                    field: "name".to_string(),
                    label: "".to_string(),
                    input_type: InputType::Text,
                    default_value: Some(user.name.clone()),
                    ..Default::default()
                }),
            ),
            DetailField::new(
                "Email",
                user.email.clone(),
                ComponentNode::input("email", InputProps {
                    field: "email".to_string(),
                    label: "".to_string(),
                    input_type: InputType::Email,
                    default_value: Some(user.email.clone()),
                    ..Default::default()
                }),
            ),
        ],
        edit_url: format!("/users/{}?mode=edit", user.id),
        cancel_url: format!("/users/{}", user.id),
        edit_label: None,
        save_label: None,
        cancel_label: None,
        method: Some(HttpMethod::Put),
    },
);
}

JSON output (mode = Edit):

{
  "key": "user-detail",
  "type": "DetailForm",
  "mode": "edit",
  "action": { "handler": "users.update", "method": "PUT" },
  "fields": [
    {
      "label": "Name",
      "value": "Ada Lovelace",
      "input": { "type": "Input", "field": "name", "label": "", "input_type": "text", "default_value": "Ada Lovelace" }
    },
    {
      "label": "Email",
      "value": "ada@example.com",
      "input": { "type": "Input", "field": "email", "label": "", "input_type": "email", "default_value": "ada@example.com" }
    }
  ],
  "edit_url": "/users/1?mode=edit",
  "cancel_url": "/users/1",
  "method": "PUT"
}

Authoring rule (Option A). When a DetailField.input is an Input, Select, Textarea, Checkbox, or Switch component, the caller MUST set its label prop to the empty string "". The <dt> already provides the visible label; a non-empty input label produces duplicate UI text. DetailForm does not mutate caller-supplied props. For accessibility, callers SHOULD also set aria-label on each input derived from the field's label value so screen readers retain the field name.

Not included in v1. Client-side mode toggle (no JS). Optimistic updates. Per-field mode override. Custom action buttons beyond Modifica/Salva/Annulla. Top-level error banner. i18n binding for default labels (currently Italian literals).

Badge

Small label with variant-based styling.

PropTypeRequiredDefaultDescription
labelStringYes-Badge text
variantBadgeVariantNoDefaultVisual style
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, BadgeProps, BadgeVariant};

ComponentNode {
    key: "status".to_string(),
    component: Component::Badge(BadgeProps {
        label: "Active".to_string(),
        variant: BadgeVariant::Default,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "status", "type": "Badge", "label": "Active", "variant": "default" }

Avatar

User avatar with image source, fallback text, and size variants.

PropTypeRequiredDefaultDescription
srcOption<String>NoNoneImage URL
altStringYes-Alt text (required for accessibility)
fallbackOption<String>NoNoneFallback initials when no image
sizeOption<Size>NoDefaultAvatar size: xs, sm, default, lg
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, AvatarProps, Size};

ComponentNode {
    key: "user-avatar".to_string(),
    component: Component::Avatar(AvatarProps {
        src: Some("/images/alice.jpg".to_string()),
        alt: "Alice Johnson".to_string(),
        fallback: Some("AJ".to_string()),
        size: Some(Size::Lg),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "user-avatar", "type": "Avatar", "alt": "Alice Johnson", "src": "/images/alice.jpg", "fallback": "AJ", "size": "lg" }

Image

Renders a bounded visual asset — either an external image via URL, or a server-constructed inline SVG.

Props

NameTypeRequiredDescription
srcStringone-ofImage source URL (URL variant). Attribute is HTML-escaped.
svgStringone-ofInline SVG emitted verbatim (SVG variant). See Safety note below.
altStringyesAlt text for accessibility — required on both variants (compile-enforced).
aspect_ratioOption<String>noCSS aspect ratio (e.g., "16/9").
placeholder_labelOption<String>noLabel shown in the skeleton placeholder (URL variant only).

Exactly one of src or svg must be set. Backward-compatibility note: existing JSON sending {"type":"Image","src":"…","alt":"…"} continues to work unchanged.

Safety note — svg variant: The svg value is emitted verbatim without HTML escaping. Intended for server-constructed SVG (charts, sparklines, icons). Not suitable for user-supplied strings. Callers that incorporate user data into the SVG output are responsible for sanitization before constructing the svg variant. The alt attribute is HTML-escaped on both variants.

Rust

#![allow(unused)]
fn main() {
use ferro_json_ui::{ComponentNode, ImageProps};

// URL variant
let url_node = ComponentNode::image(
    "hero",
    ImageProps::url("/img/hero.png", "Hero image"),
);

// SVG variant — server-constructed chart (e.g. from a Rust helper)
let svg = bar_chart_svg(&weekly_data, 800, 300);
let chart_node = ComponentNode::image(
    "revenue-chart",
    ImageProps::inline_svg(svg, "Incassi settimanali: 150€ lun, 320€ mar, …"),
);
}

JSON

URL variant:

{
  "type": "Image",
  "src": "/img/hero.png",
  "alt": "Hero image",
  "aspect_ratio": "16/9"
}

SVG variant:

{
  "type": "Image",
  "svg": "<svg viewBox=\"0 0 800 300\">…</svg>",
  "alt": "Incassi settimanali: 150€ lun, 320€ mar, …"
}

Use cases for the SVG variant

  • Server-rendered charts (bar, line, sparkline)
  • Diagrams assembled from typed data on the server
  • Decorative vector assets constructed by Rust code
  • Server-rendered icon sets

No generic HTML escape hatch. For rendering HTML (not SVG), no generic HtmlEmbed-style component exists — by design. If a real use-case demands HTML embedding, author a narrower component scoped to the specific content shape rather than a generic string-to-HTML escape hatch.

Text

Renders text content with semantic HTML element selection.

PropTypeRequiredDefaultDescription
contentStringYes-Text content
elementTextElementNoPHTML element: p, h1, h2, h3, span, div, section
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, TextProps, TextElement};

ComponentNode {
    key: "heading".to_string(),
    component: Component::Text(TextProps {
        content: "Welcome to the dashboard".to_string(),
        element: TextElement::H1,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "heading", "type": "Text", "content": "Welcome to the dashboard", "element": "h1" }

Progress

Progress bar with percentage value.

PropTypeRequiredDefaultDescription
valueu8Yes-Percentage value (0-100)
maxOption<u8>NoNoneMaximum value
labelOption<String>NoNoneLabel text above the bar
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, ProgressProps};

ComponentNode {
    key: "upload-progress".to_string(),
    component: Component::Progress(ProgressProps {
        value: 75,
        max: Some(100),
        label: Some("Uploading...".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "upload-progress", "type": "Progress", "value": 75, "max": 100, "label": "Uploading..." }

Navigation breadcrumb trail. The last item typically has no URL (current page).

PropTypeRequiredDefaultDescription
itemsVec<BreadcrumbItem>Yes-Breadcrumb items

BreadcrumbItem defines a breadcrumb entry:

FieldTypeRequiredDescription
labelStringYesBreadcrumb text
urlOption<String>NoLink URL (omit for the current page)
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, BreadcrumbProps, BreadcrumbItem};

ComponentNode {
    key: "breadcrumbs".to_string(),
    component: Component::Breadcrumb(BreadcrumbProps {
        items: vec![
            BreadcrumbItem { label: "Home".to_string(), url: Some("/".to_string()) },
            BreadcrumbItem { label: "Users".to_string(), url: Some("/users".to_string()) },
            BreadcrumbItem { label: "Edit User".to_string(), url: None },
        ],
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "breadcrumbs",
  "type": "Breadcrumb",
  "items": [
    { "label": "Home", "url": "/" },
    { "label": "Users", "url": "/users" },
    { "label": "Edit User" }
  ]
}

Pagination

Page navigation for paginated data. Computes page count from total and per_page.

PropTypeRequiredDefaultDescription
current_pageu32Yes-Current page number
per_pageu32Yes-Items per page
totalu32Yes-Total number of items
base_urlOption<String>NoNoneBase URL for page links
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, PaginationProps};

ComponentNode {
    key: "users-pagination".to_string(),
    component: Component::Pagination(PaginationProps {
        current_page: 1,
        per_page: 25,
        total: 150,
        base_url: Some("/users".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "users-pagination", "type": "Pagination", "current_page": 1, "per_page": 25, "total": 150, "base_url": "/users" }

StatCard

Live-updatable metric card with an optional SSE target for real-time value updates. Used in dashboards to display KPIs, counts, and monetary totals.

PropTypeRequiredDefaultDescription
labelStringYes-Metric label (e.g., "Total Revenue")
valueStringYes-Current metric value (e.g., "€12,345")
iconOption<String>NoNoneIcon name
subtitleOption<String>NoNoneSecondary text below the value
sse_targetOption<String>NoNoneSSE event target key for live updates

The sse_target field connects this card to the JS runtime's SSE listener. When the server emits a Server-Sent Event with a matching key, the runtime updates the displayed value without a page reload:

#![allow(unused)]
fn main() {
use ferro::{ComponentNode, StatCardProps};

let node = ComponentNode::stat_card("revenue", StatCardProps {
    label: "Total Revenue".to_string(),
    value: "€12,345".to_string(),
    icon: Some("currency-euro".to_string()),
    subtitle: Some("This month".to_string()),
    sse_target: Some("revenue_total".to_string()),
});
}

The server sends updates via SSE as JSON:

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

JSON output:

{
  "key": "revenue",
  "type": "StatCard",
  "label": "Total Revenue",
  "value": "€12,345",
  "icon": "currency-euro",
  "subtitle": "This month",
  "sse_target": "revenue_total"
}

Forms Components

Form

Form container with action binding and field components. The action defines the submit endpoint.

PropTypeRequiredDefaultDescription
actionActionYes-Action to execute on form submit
fieldsVec<ComponentNode>Yes-Form field components (Input, Select, Checkbox, etc.)
methodOption<HttpMethod>NoNoneHTTP method override (GET, POST, PUT, PATCH, DELETE)
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, FormProps, InputProps, InputType, Action};

ComponentNode {
    key: "create-form".to_string(),
    component: Component::Form(FormProps {
        action: Action::new("users.store"),
        fields: vec![
            ComponentNode {
                key: "name-input".to_string(),
                component: Component::Input(InputProps {
                    field: "name".to_string(),
                    label: "Name".to_string(),
                    input_type: InputType::Text,
                    placeholder: Some("Enter name".to_string()),
                    required: Some(true),
                    disabled: None,
                    error: None,
                    description: None,
                    default_value: None,
                    data_path: None,
                    step: None,
                }),
                action: None,
                visibility: None,
            },
        ],
        method: None,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "create-form",
  "type": "Form",
  "action": { "handler": "users.store", "method": "POST" },
  "fields": [
    { "key": "name-input", "type": "Input", "field": "name", "label": "Name", "placeholder": "Enter name", "required": true }
  ]
}

Input

Text input field with type variants, validation error display, and data binding.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Input label text
input_typeInputTypeNoTextInput type
placeholderOption<String>NoNonePlaceholder text
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation error message
descriptionOption<String>NoNoneHelp text below the input
default_valueOption<String>NoNonePre-filled value
data_pathOption<String>NoNoneData path for pre-filling from handler data (e.g., "/data/user/name")
stepOption<String>NoNoneHTML step attribute for number inputs (e.g., "any", "0.01")

InputType variants:

ValueSerialized
InputType::Text"text"
InputType::Email"email"
InputType::Password"password"
InputType::Number"number"
InputType::Textarea"textarea"
InputType::Hidden"hidden"
InputType::Date"date"
InputType::Time"time"
InputType::Url"url"
InputType::Tel"tel"
InputType::Search"search"
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, InputProps, InputType};

ComponentNode {
    key: "email-input".to_string(),
    component: Component::Input(InputProps {
        field: "email".to_string(),
        label: "Email Address".to_string(),
        input_type: InputType::Email,
        placeholder: Some("user@example.com".to_string()),
        required: Some(true),
        disabled: None,
        error: None,
        description: Some("Your work email".to_string()),
        default_value: None,
        data_path: Some("/data/user/email".to_string()),
        step: None,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

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

Select

Dropdown select field with options, validation error, and data binding.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Select label text
optionsVec<SelectOption>Yes-Options list
placeholderOption<String>NoNonePlaceholder text
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation error message
descriptionOption<String>NoNoneHelp text below the select
default_valueOption<String>NoNonePre-selected value
data_pathOption<String>NoNoneData path for pre-filling from handler data

SelectOption defines a value-label pair:

FieldTypeRequiredDescription
valueStringYesOption value submitted with the form
labelStringYesDisplay text shown to the user
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, SelectProps, SelectOption};

ComponentNode {
    key: "role-select".to_string(),
    component: Component::Select(SelectProps {
        field: "role".to_string(),
        label: "Role".to_string(),
        options: vec![
            SelectOption { value: "admin".to_string(), label: "Administrator".to_string() },
            SelectOption { value: "editor".to_string(), label: "Editor".to_string() },
            SelectOption { value: "viewer".to_string(), label: "Viewer".to_string() },
        ],
        placeholder: Some("Select a role".to_string()),
        required: Some(true),
        disabled: None,
        error: None,
        description: None,
        default_value: None,
        data_path: Some("/data/user/role".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "role-select",
  "type": "Select",
  "field": "role",
  "label": "Role",
  "placeholder": "Select a role",
  "required": true,
  "data_path": "/data/user/role",
  "options": [
    { "value": "admin", "label": "Administrator" },
    { "value": "editor", "label": "Editor" },
    { "value": "viewer", "label": "Viewer" }
  ]
}

Checkbox

Boolean checkbox field with label, description, and data binding.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Checkbox label text
descriptionOption<String>NoNoneHelp text below the checkbox
checkedOption<bool>NoNoneDefault checked state
data_pathOption<String>NoNoneData path for pre-filling from handler data
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation error message
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, CheckboxProps};

ComponentNode {
    key: "terms-checkbox".to_string(),
    component: Component::Checkbox(CheckboxProps {
        field: "terms".to_string(),
        label: "Accept Terms of Service".to_string(),
        description: Some("You must accept to continue.".to_string()),
        checked: None,
        data_path: None,
        required: Some(true),
        disabled: None,
        error: None,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "terms-checkbox", "type": "Checkbox", "field": "terms", "label": "Accept Terms of Service", "description": "You must accept to continue.", "required": true }

Switch

Toggle switch — a visual alternative to Checkbox with identical props. The frontend renderer handles the visual difference.

PropTypeRequiredDefaultDescription
fieldStringYes-Form field name for data binding
labelStringYes-Switch label text
descriptionOption<String>NoNoneHelp text below the switch
checkedOption<bool>NoNoneDefault checked state
data_pathOption<String>NoNoneData path for pre-filling from handler data
requiredOption<bool>NoNoneWhether the field is required
disabledOption<bool>NoNoneWhether the field is disabled
errorOption<String>NoNoneValidation error message
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, SwitchProps};

ComponentNode {
    key: "notifications-switch".to_string(),
    component: Component::Switch(SwitchProps {
        field: "notifications".to_string(),
        label: "Enable Notifications".to_string(),
        description: Some("Receive email notifications".to_string()),
        checked: Some(true),
        data_path: Some("/data/user/notifications_enabled".to_string()),
        required: None,
        disabled: None,
        error: None,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "notifications-switch", "type": "Switch", "field": "notifications", "label": "Enable Notifications", "description": "Receive email notifications", "checked": true, "data_path": "/data/user/notifications_enabled" }

Button

Interactive button with visual variants, sizing, and optional icon.

PropTypeRequiredDefaultDescription
labelStringYes-Button label text
variantButtonVariantNoDefaultVisual style
sizeSizeNoDefaultButton size
disabledOption<bool>NoNoneWhether the button is disabled
iconOption<String>NoNoneIcon name
icon_positionOption<IconPosition>NoLeftIcon placement: left or right

Buttons are typically combined with an action on the ComponentNode to bind click behavior:

#![allow(unused)]
fn main() {
use ferro::{ComponentNode, ButtonProps, ButtonVariant, Size, IconPosition};

let node = ComponentNode::button("save-btn", ButtonProps {
    label: "Save Changes".to_string(),
    variant: ButtonVariant::Default,
    size: Size::Default,
    disabled: None,
    icon: Some("save".to_string()),
    icon_position: Some(IconPosition::Left),
});
}

JSON output:

{ "key": "save-btn", "type": "Button", "label": "Save Changes", "variant": "default", "size": "default", "icon": "save", "icon_position": "left" }

Feedback Components

Alert

Alert message with variant-based styling and optional title.

PropTypeRequiredDefaultDescription
messageStringYes-Alert message content
variantAlertVariantNoInfoVisual style
titleOption<String>NoNoneAlert title
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, AlertProps, AlertVariant};

ComponentNode {
    key: "warning".to_string(),
    component: Component::Alert(AlertProps {
        message: "Your trial expires in 3 days.".to_string(),
        variant: AlertVariant::Warning,
        title: Some("Trial Ending".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "warning", "type": "Alert", "message": "Your trial expires in 3 days.", "variant": "warning", "title": "Trial Ending" }

Toast

Declarative notification intent rendered by the JS runtime. When a Toast component is included in a view, the runtime displays it as an overlay notification (top-right corner) and dismisses it after the configured timeout.

PropTypeRequiredDefaultDescription
messageStringYes-Toast message content
variantToastVariantNoInfoVisual style (info, success, warning, error)
timeoutOption<u32>NoNoneSeconds before auto-dismiss (default: 5)
dismissibleboolNotrueWhether the user can dismiss the toast
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, ToastProps, ToastVariant};

ComponentNode {
    key: "save-toast".to_string(),
    component: Component::Toast(ToastProps {
        message: "Changes saved successfully.".to_string(),
        variant: ToastVariant::Success,
        timeout: Some(3),
        dismissible: true,
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{ "key": "save-toast", "type": "Toast", "message": "Changes saved successfully.", "variant": "success", "timeout": 3, "dismissible": true }

Sidebar navigation shell with fixed top items, grouped items, and fixed bottom items. Typically used as a component inside a DashboardLayout. When used standalone it renders as a vertical navigation panel.

PropTypeRequiredDefaultDescription
fixed_topVec<SidebarNavItem>No[]Items pinned at the top (e.g., logo/home link)
groupsVec<SidebarGroup>No[]Collapsible navigation groups
fixed_bottomVec<SidebarNavItem>No[]Items pinned at the bottom (e.g., settings, logout)

SidebarNavItem defines a navigation link:

FieldTypeRequiredDescription
labelStringYesLink text
hrefStringYesLink URL
iconOption<String>NoIcon name
activeboolNo (default: false)Whether this is the current page

SidebarGroup defines a labeled, collapsible group:

FieldTypeRequiredDescription
labelStringYesGroup heading text
collapsedboolNo (default: false)Whether the group starts collapsed
itemsVec<SidebarNavItem>YesNavigation links in this group
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, SidebarProps, SidebarNavItem, SidebarGroup};

ComponentNode {
    key: "sidebar".to_string(),
    component: Component::Sidebar(SidebarProps {
        fixed_top: vec![
            SidebarNavItem { label: "Dashboard".to_string(), href: "/".to_string(), icon: Some("home".to_string()), active: true },
        ],
        groups: vec![
            SidebarGroup {
                label: "Management".to_string(),
                collapsed: false,
                items: vec![
                    SidebarNavItem { label: "Users".to_string(), href: "/users".to_string(), icon: Some("users".to_string()), active: false },
                    SidebarNavItem { label: "Orders".to_string(), href: "/orders".to_string(), icon: Some("shopping-bag".to_string()), active: false },
                ],
            },
        ],
        fixed_bottom: vec![
            SidebarNavItem { label: "Settings".to_string(), href: "/settings".to_string(), icon: Some("cog".to_string()), active: false },
        ],
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "sidebar",
  "type": "Sidebar",
  "fixed_top": [{ "label": "Dashboard", "href": "/", "icon": "home", "active": true }],
  "groups": [
    {
      "label": "Management",
      "collapsed": false,
      "items": [
        { "label": "Users", "href": "/users", "icon": "users", "active": false },
        { "label": "Orders", "href": "/orders", "icon": "shopping-bag", "active": false }
      ]
    }
  ],
  "fixed_bottom": [{ "label": "Settings", "href": "/settings", "icon": "cog", "active": false }]
}

Application header shell with business name, user info, notification count, and logout link. Typically used inside DashboardLayout.

PropTypeRequiredDefaultDescription
business_nameStringYes-Application name shown in the header
notification_countOption<u32>NoNoneUnread notification count for badge display
user_nameOption<String>NoNoneCurrent user's name
user_avatarOption<String>NoNoneCurrent user's avatar URL
logout_urlOption<String>NoNoneURL for the logout link
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, HeaderProps};

ComponentNode {
    key: "header".to_string(),
    component: Component::Header(HeaderProps {
        business_name: "My App".to_string(),
        notification_count: Some(3),
        user_name: Some("Alice Johnson".to_string()),
        user_avatar: None,
        logout_url: Some("/logout".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "header",
  "type": "Header",
  "business_name": "My App",
  "notification_count": 3,
  "user_name": "Alice Johnson",
  "logout_url": "/logout"
}

NotificationDropdown

A dropdown list of notification items, typically rendered inside a Header. Displays a list of recent notifications with read/unread state, timestamps, and optional action URLs.

PropTypeRequiredDefaultDescription
notificationsVec<NotificationItem>Yes-List of notifications
empty_textOption<String>NoNoneText to show when the list is empty

NotificationItem defines a single notification:

FieldTypeRequiredDescription
textStringYesNotification message
iconOption<String>NoIcon name
timestampOption<String>NoTime string (e.g., "2 minutes ago")
readboolNo (default: false)Whether the notification has been read
action_urlOption<String>NoURL to navigate to on click
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, NotificationDropdownProps, NotificationItem};

ComponentNode {
    key: "notifications".to_string(),
    component: Component::NotificationDropdown(NotificationDropdownProps {
        notifications: vec![
            NotificationItem {
                icon: Some("bell".to_string()),
                text: "New order received".to_string(),
                timestamp: Some("5 minutes ago".to_string()),
                read: false,
                action_url: Some("/orders/123".to_string()),
            },
            NotificationItem {
                icon: None,
                text: "Payment processed".to_string(),
                timestamp: Some("1 hour ago".to_string()),
                read: true,
                action_url: None,
            },
        ],
        empty_text: Some("No new notifications".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "notifications",
  "type": "NotificationDropdown",
  "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 }
  ]
}

Onboarding Components

Checklist

Step-by-step onboarding or task checklist with dismissal and optional server-side state persistence.

PropTypeRequiredDefaultDescription
titleStringYes-Checklist title
itemsVec<ChecklistItem>Yes-Checklist items
dismissibleboolNotrueWhether the checklist can be dismissed
dismiss_labelOption<String>NoNoneCustom dismiss button label
data_keyOption<String>NoNoneServer-side state persistence key

ChecklistItem defines a checklist step:

FieldTypeRequiredDescription
labelStringYesStep description
checkedboolNo (default: false)Whether this step is complete
hrefOption<String>NoLink to take the step
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, ChecklistProps, ChecklistItem};

ComponentNode {
    key: "setup-checklist".to_string(),
    component: Component::Checklist(ChecklistProps {
        title: "Get Started".to_string(),
        items: vec![
            ChecklistItem { label: "Create your account".to_string(), checked: true, href: None },
            ChecklistItem { label: "Set up billing".to_string(), checked: false, href: Some("/billing".to_string()) },
            ChecklistItem { label: "Invite your team".to_string(), checked: false, href: Some("/team/invite".to_string()) },
        ],
        dismissible: true,
        dismiss_label: Some("Done".to_string()),
        data_key: Some("onboarding_checklist".to_string()),
    }),
    action: None,
    visibility: None,
}
}

JSON output:

{
  "key": "setup-checklist",
  "type": "Checklist",
  "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" }
  ]
}

Extensible Components

Plugin

Passes through to a registered plugin component. The plugin_type field selects the plugin from the global registry.

PropTypeRequiredDescription
plugin_typeStringYesRegistered plugin type name (e.g., "Map")
propsserde_json::ValueYesRaw props passed to the plugin's render function
#![allow(unused)]
fn main() {
use ferro::{ComponentNode, Component, PluginProps};

ComponentNode {
    key: "office-map".to_string(),
    component: Component::Plugin(PluginProps {
        plugin_type: "Map".to_string(),
        props: serde_json::json!({
            "center": [51.505, -0.09],
            "zoom": 13,
            "markers": [
                { "lat": 51.505, "lng": -0.09, "popup": "Our office" }
            ]
        }),
    }),
    action: None,
    visibility: None,
}
}

See Plugins for the full plugin guide — how to build, register, and use custom plugin components.

JSON output:

{
  "key": "office-map",
  "type": "Map",
  "center": [51.505, -0.09],
  "zoom": 13,
  "markers": [{ "lat": 51.505, "lng": -0.09, "popup": "Our office" }]
}

JSON Serialization

Every component tree serializes to JSON via serde. The Component enum uses serde's tagged representation, producing a "type" field that identifies the component:

{
  "key": "welcome-card",
  "type": "Card",
  "title": "Welcome",
  "description": "Your dashboard",
  "children": [
    {
      "key": "greeting",
      "type": "Text",
      "content": "Hello, Alice!",
      "element": "h2"
    }
  ]
}

The ComponentNode fields (key, action, visibility) are flattened into the same JSON object alongside the component-specific props. This produces clean, predictable JSON that frontend renderers can consume directly.

Optional fields with None values are omitted from serialization (skip_serializing_if = "Option::is_none"). Default values for enums (e.g., ButtonVariant::Default) are always included.