Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Rinch

A GUI framework for Rust that uses HTML and CSS for layout, renders natively, and won’t make you mass a single .clone() at a signal.

Live Demo — All Rinch components, running in your browser via WASM. Or run locally: cargo run --release -p ui-zoo-desktop

Philosophy

  • HTML/CSS layout — The layout system that billions of people have already debugged for you, powered by Servo’s Stylo and Taffy’s flexbox.
  • Fine-grained reactivity — Signal changes update one DOM node, not a component tree. No virtual DOM. No diffing.
  • Components run once — Your function builds the DOM, closures keep it updated. That’s the whole model.
  • Native performance — GPU rendering via Vello/wgpu, or software rendering via tiny-skia. Pick at compile time.
  • WASM too — Same components, same signals, browser-native DOM. ~3MB binary, zero JavaScript.

Quick Example

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    let count = Signal::new(0);

    rsx! {
        div {
            p { "Count: " {|| count.get().to_string()} }
            button { onclick: move || count.update(|n| *n += 1), "+" }
        }
    }
}

fn main() {
    run("Counter", 800, 600, app);
}

Signal is Copy. No .clone() before closures. The {|| ...} closure creates an Effect that tracks its dependencies and surgically updates that text node when count changes. The app function runs once, builds the DOM, and never runs again.

What’s Included

  • 60+ Components — Buttons, inputs, modals, tabs, accordions, color pickers, trees, a rich text editor, and more.
  • Theme System — 14 color palettes, dark mode, CSS variables for everything. Mantine-inspired.
  • 5,000+ Icons — Tabler Icons with a type-safe enum. Dead code elimination drops the ones you don’t use.
  • Native Platform — Menus, file dialogs, clipboard, system tray, transparent windows.
  • Developer Tools — F12 DevTools, inspect mode, MCP server for Claude Code integration.
  • Game Engine Embedding — Submit GPU textures or CPU pixels into a Rinch UI, or embed Rinch into your own render loop.

Getting Started

From zero to window in about ninety seconds.

Prerequisites

  • Rust nightly (Rinch uses a few unstable features — let_chains, etc.)
  • A C/C++ compiler (Stylo has native dependencies)
  • On Linux: libfontconfig-dev and pkg-config (for font discovery)

Create a Project

cargo new my-app
cd my-app

Add Rinch

# Cargo.toml
[dependencies]
rinch = { git = "https://github.com/joeleaver/rinch.git", features = ["desktop", "components", "theme"] }

What the features do:

  • "desktop" — windowing, event loop, software renderer. Required for desktop apps.
  • "components" — the 60+ component library (Button, TextInput, Modal, etc.).
  • "theme" — CSS variable generation, color palettes, dark mode. Auto-enabled by "components".
  • "gpu" — GPU rendering via Vello/wgpu instead of the software renderer. Optional.
  • "clipboard" — clipboard read/write. Optional.
  • "file-dialogs" — native open/save dialogs. Optional.

Write Your App

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    let count = Signal::new(0);

    rsx! {
        Stack { gap: "md", p: "xl",
            Title { order: 1, "Hello, Rinch!" }
            Text { color: "dimmed", "Your first app. It only goes up from here." }

            Group { gap: "sm",
                Button { onclick: move || count.update(|n| *n += 1), "+" }
                Button { variant: "outline", onclick: move || count.set(0), "Reset" }
            }

            Text { size: "xl", {|| format!("Count: {}", count.get())} }
        }
    }
}

fn main() {
    run_with_theme("My App", 800, 600, app, ThemeProviderProps {
        primary_color: Some("cyan".into()),
        ..Default::default()
    });
}

Run It

cargo run --release

Always use --release for running apps. Debug mode is slow because Stylo and Parley do serious work. Release builds are fast.

You should see a window with a title, description, two buttons, and a count that updates when you click “+”.

What Just Happened

  1. #[component] injected a __scope: &mut RenderScope parameter. You never see it, but rsx! needs it.
  2. rsx! built a DOM tree: created elements, set attributes, wired event handlers.
  3. {|| format!("Count: {}", count.get())} created an Effect that reads count and updates a text node. When count changes, that closure re-runs and updates only that text node.
  4. run_with_theme opened a window, loaded theme CSS, mounted the component, and started the event loop.

Your app function ran exactly once. It will never run again. The closures handle everything from here.

What’s Next

RSX Syntax

RSX is a JSX-like syntax for building UI in Rust. It lets you write HTML-like markup directly in your Rust code.

How RSX Works

The rsx! macro generates DOM construction code that:

  1. Creates DOM nodes via RenderScope
  2. Sets up Effects for reactive expressions {|| ...}
  3. Wires event handlers

This enables fine-grained reactive updates - when a signal changes, only the affected DOM nodes are updated, not the entire tree.

Component Signature

Components use the #[component] attribute macro and return a NodeHandle:

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn my_component() -> NodeHandle {
    rsx! {
        div { "Hello from my component!" }
    }
}
}

The #[component] macro injects the __scope: &mut RenderScope parameter automatically, which is used by rsx! to create DOM nodes and Effects.

Basic Syntax

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn example() -> NodeHandle {
    rsx! {
        div {
            h1 { "Hello, World!" }
            p { "This is a paragraph." }
        }
    }
}
}

HTML Elements

Standard HTML elements are written in lowercase:

#![allow(unused)]
fn main() {
rsx! {
    div {
        span { "Text content" }
        button { "Click me" }
        input { type: "text", placeholder: "Enter text..." }
    }
}
}

Attributes

Attributes are specified after the element name:

#![allow(unused)]
fn main() {
rsx! {
    div { class: "container", id: "main",
        a { href: "https://example.com", "Link text" }
        img { src: "image.png", alt: "Description" }
    }
}
}

Event Handlers

Event handlers are on* attributes on HTML elements. Pointer/click handlers are Fn() closures; read the cursor position, button, and modifier keys from get_click_context():

#![allow(unused)]
fn main() {
use rinch::prelude::*;

rsx! {
    div {
        onclick: || println!("clicked"),
        onmousedown: || {
            let ctx = get_click_context();
            match ctx.button {
                MouseButton::Right => println!("right press"),
                _ if ctx.modifiers.shift => println!("shift+press"),
                _ => println!("press at {}, {}", ctx.mouse_x, ctx.mouse_y),
            }
        },
        onmouseup: || println!("released"),
        onmousemove: || { /* fires on every move over this element */ },
        onmouseenter: || println!("hover in"),
        onmouseleave: || println!("hover out"),
        oncontextmenu: || println!("right-click menu"),
        "Interactive"
    }
}
}

Supported HTML-element event attributes:

AttributeFiresClosure
onclickPrimary press (rinch dispatches click on mousedown)Fn()
onmousedown / onmouseupPointer press / release (any button)Fn()
onmousemovePointer moves over the elementFn()
onmouseenter / onmouseleavePointer enters / leaves the elementFn()
oncontextmenuRight-click (suppresses the native menu when handled)Fn()
oninput / onchange<input>/<textarea> value changeFn(String)
onscrollScroll container scrollsFn(f64) (scrollTop)
ondragstartondrop, ondragendElement drag-and-dropFn()
onfiledrop, onfiledragenter/onfiledragleaveOS → app file dropFn(Vec<PathBuf>) / Fn()

Mouse and click handlers read per-event data from get_click_context(): mouse_x/mouse_y, element bounds (relative_x(), percent_x(), …), button (MouseButton::{Left, Middle, Right}), and modifiers (shift/ctrl/alt/meta). These behave identically on the desktop and web (WASM) backends.

Sizing a <textarea>

A <textarea> holds its value in an attribute rather than as child text, so it has no content to size against. Its height comes from rows, which reserves that many lines plus padding and border — 2 rows when unset, matching HTML:

#![allow(unused)]
fn main() {
rsx! {
    textarea { rows: "6" }                        // six lines tall
    textarea { style: "min-height: 200px;" }      // or size it with CSS
}
}

min-height and an explicit height both work as usual: height overrides the rows height outright, and min-height acts as a floor, so the taller of the two wins. The Textarea component exposes the same thing as min_rows.

Components

Components are custom UI elements written in PascalCase. They implement the Component trait and render directly to DOM nodes:

#![allow(unused)]
fn main() {
rsx! {
    Button { variant: "filled", color: "blue", onclick: || println!("clicked"),
        "Click me"
    }

    Alert { icon: Icon::InfoCircle, color: "blue", title: "Info",
        "This is an informational message."
    }
}
}

Note: Shell-level constructs like windows, menus, and themes are configured at the runtime level via props, not in RSX.

Fragments

Use empty braces to group multiple elements without a wrapper:

#![allow(unused)]
fn main() {
rsx! {
    div {
        // Multiple children without extra wrapper
        span { "First" }
        span { "Second" }
    }
}
}

Text Content

Text can be included directly in elements:

#![allow(unused)]
fn main() {
rsx! {
    p { "This is text content" }
    span { "Multiple " "strings " "concatenated" }
}
}

Expressions

Rust expressions can be embedded in curly braces:

#![allow(unused)]
fn main() {
let name = "World";
let count = 42;

rsx! {
    p { "Hello, " {name} "!" }
    p { "Count: " {count} }
}
}

Reactive Expressions

For fine-grained reactivity, use closure syntax {|| expr} to create expressions that automatically update when signals change. Without the closure, values are captured once at initial render and never update.

Static vs Reactive

#![allow(unused)]
fn main() {
let count = Signal::new(0);

rsx! {
    // ❌ STATIC: Captured once, never updates
    p { "Count: " {count.get()} }

    // ✅ REACTIVE: Creates an effect, updates when count changes
    p { "Count: " {|| count.get().to_string()} }
}
}

Reactive Text

Wrap dynamic text in a closure to make it reactive:

#![allow(unused)]
fn main() {
let name = Signal::new("World".to_string());
let count = Signal::new(0);

rsx! {
    // Simple reactive text
    h1 { "Hello, " {|| name.get()} "!" }

    // Reactive with formatting
    p { {|| format!("You clicked {} times", count.get())} }

    // Conditional text
    p { {|| if count.get() > 10 { "Many clicks!" } else { "Keep clicking" }} }
}
}

Reactive Styles

Style attributes can also be reactive:

#![allow(unused)]
fn main() {
let progress = Signal::new(50);
let is_active = Signal::new(false);

rsx! {
    // Reactive width
    div {
        class: "progress-bar",
        style: {|| format!("width: {}%", progress.get())}
    }

    // Multiple reactive style properties
    div {
        style: {|| format!(
            "background: {}; opacity: {}",
            if is_active.get() { "blue" } else { "gray" },
            if is_active.get() { "1" } else { "0.5" }
        )}
    }
}
}

Reactive Classes

Dynamically change CSS classes based on state:

#![allow(unused)]
fn main() {
let is_selected = Signal::new(false);
let variant = Signal::new("primary");

rsx! {
    // Conditional class
    div {
        class: {|| if is_selected.get() { "item selected" } else { "item" }}
    }

    // Dynamic class from state
    button {
        class: {|| format!("btn btn-{}", variant.get())}
    }
}
}

Important: Closure Must Be the Direct Expression

The {|| ...} pattern requires the closure to be the direct expression inside the braces. Block expressions with setup code now work:

#![allow(unused)]
fn main() {
let count = Signal::new(0);

rsx! {
    // ✅ CORRECT: Closure is the direct expression
    div { style: {|| format!("width: {}px", count.get() * 10)} }

    // ✅ CORRECT: Block with setup + final closure — works!
    div { style: {
        let multiplier = 10;
        move || format!("width: {}px", count.get() * multiplier)
    }}

    // ✅ CORRECT: Compute inside the closure
    div { style: {move || {
        let multiplier = 10;
        format!("width: {}px", count.get() * multiplier)
    }}}
}
}

You can compute intermediate values outside the RSX, inside the closure body, or in a setup block before the closure.

Why Closures?

Rust macros operate on syntax, not types. The macro cannot distinguish between:

  • signal.get() - reading reactive state
  • hashmap.get("key") - reading a regular value

The closure {|| ...} explicitly marks the expression as reactive, telling Rinch to:

  1. Create an Effect that wraps this expression
  2. Track which signals are read inside the closure
  3. Re-run and update the DOM when those signals change

This approach has benefits:

  • Zero runtime overhead for static content
  • Clear intent - you know exactly what’s reactive
  • Works with Rust’s ownership - closures capture values correctly

What Supports Reactive Expressions?

ContextFine-Grained?Notes
Text content✅ Yes`{
style attribute✅ YesUpdates specific element’s style
class attribute✅ YesUpdates specific element’s class
Portal content✅ YesContent inside portals is reactive
if/else blocks✅ YesNative reactive conditional rendering
for loops✅ YesNative reactive list rendering with keyed reconciliation
match blocks✅ YesNative reactive multi-branch rendering
.iter().map()✅ YesCreates a display:contents wrapper for the Vec
Window/MenuN/ANative OS elements, not DOM

Control Flow

Rinch supports native Rust control flow directly in RSX. All control flow is always reactive — the conditions, iterators, and scrutinees are automatically wrapped in closures and tracked by Effects. When the underlying signals change, only the affected branches are updated.

if / else

Write standard Rust if/else directly in RSX:

#![allow(unused)]
fn main() {
let visible = Signal::new(true);
let count = Signal::new(5);

rsx! {
    div {
        // Simple if
        if visible.get() {
            p { "I'm visible!" }
        }

        // if/else
        if count.get() > 10 {
            p { "Big number!" }
        } else {
            p { "Small number" }
        }

        // if/else if/else
        if count.get() > 100 {
            p { "Huge" }
        } else if count.get() > 10 {
            p { "Big" }
        } else {
            p { "Small" }
        }
    }
}
}

if let

Pattern matching with if let is also supported:

#![allow(unused)]
fn main() {
let current_user = Signal::new(Some("Alice".to_string()));

rsx! {
    div {
        if let Some(name) = current_user.get() {
            p { "Welcome, " {name} "!" }
        } else {
            p { "Please log in" }
        }
    }
}
}

How if works internally

The if block desugars to show_dom() — the same runtime function used by the Show component. The condition is auto-wrapped in a move || { ... } closure, creating an Effect that watches the signals read inside it. When the condition changes:

  1. The old branch’s scope is disposed (cleaning up nested effects)
  2. Old DOM nodes are removed
  3. New branch content is rendered with a fresh scope

for loops

Write standard for..in loops directly in RSX:

#![allow(unused)]
fn main() {
let todos = Signal::new(vec![
    Todo { id: 1, name: "Buy groceries".into() },
    Todo { id: 2, name: "Write code".into() },
    Todo { id: 3, name: "Take a walk".into() },
]);

rsx! {
    div {
        for todo in todos.get() {
            div { key: todo.id, {todo.name.clone()} }
        }
    }
}
}

Keys

Use key: on the first child element or component to enable efficient keyed reconciliation. When the list changes, items with the same key are preserved (not re-rendered), and only new/removed items trigger DOM operations:

#![allow(unused)]
fn main() {
for item in items.get() {
    div { key: item.id,
        span { {item.name.clone()} }
    }
}
}

key: also works on components — place it on the component tag directly:

#![allow(unused)]
fn main() {
for item in items.get() {
    TodoRow { key: item.id, label: item.name.clone() }
}
}

If no key: prop is provided, items are keyed by their Debug representation (fallback).

How for works internally

The for loop desugars to for_each_dom_typed(), which:

  1. Wraps the iterator in a move || { ... } closure (making it reactive)
  2. Creates a <!-- for --> comment marker in the DOM
  3. Evaluates the collection and renders initial items
  4. Creates an Effect that watches the collection
  5. When the collection changes, uses LIS-based keyed reconciliation (diff_keyed()) to compute minimal DOM operations: insert new items, remove deleted items, and reposition moved items
  6. For surviving items (same key), compares data via PartialEq — only re-renders items whose data actually changed

The loop variable is owned (T, not &T), so you can capture it directly in move closures. let bindings before the element are available in the key: expression too:

#![allow(unused)]
fn main() {
for todo in todos.get() {
    let id = todo.id;
    div { key: id,
        {todo.name.clone()}
        button {
            onclick: move || todos.update(|t| t.retain(|t| t.id != id)),
            "Delete"
        }
    }
}
}

Note: The item type must implement Clone + PartialEq + 'static for for loops to work. This enables efficient data comparison for selective re-rendering.

Reactivity in for Loops

The for loop expression itself is reactive — it reads a Signal, which creates an Effect. When that Signal changes, the loop re-evaluates and reconciles the list. Items whose data changed (per PartialEq) get their component re-created with fresh props. This means the component function runs again with the new values.

Because of this, closures on plain props inside for-loop components are unnecessary — the whole function runs again with new values when the item changes. Closures are needed for per-item Signals created with Signal::new() inside the loop body, since those change independently of the list data.

#![allow(unused)]
fn main() {
// Props are plain values from the for loop — no closure needed
#[component]
pub fn TodoItem(label: String, completed: bool) -> NodeHandle {
    // ❌ Unnecessary: `completed` is a plain bool, not a Signal.
    // The closure captures it once, but the component is re-created
    // with a fresh `completed` value when the item data changes anyway.
    rsx! { Text { style: {|| if completed { "text-decoration: line-through" } else { "" }} } }

    // ✅ Simpler: just use the value directly
    rsx! { Text { style: if completed { "text-decoration: line-through" } else { "" } } }
}

// Per-item Signal — closure IS needed
for todo in todos.get() {
    let editing = Signal::new(false);  // Per-item state
    div { key: todo.id,
        // ✅ Closure needed: `editing` is a Signal that changes independently
        span { style: {|| if editing.get() { "outline: 1px solid blue" } else { "" }} }
    }
}
}

Rule of thumb: If the value comes from a Signal (.get()), use a closure. If it comes from the for loop variable (a plain value), just use it directly.

Filtering and Transforming Collections

You can filter, map, and transform the collection inline in the for expression. The entire expression is wrapped in a reactive closure by the macro, so all Signals read inside it are tracked:

#![allow(unused)]
fn main() {
let todos = Signal::new(vec![/* ... */]);
let filter = Signal::new(Filter::All);

rsx! {
    div {
        // Filter the collection inline — .filter(), .map(), .collect() all work
        for todo in todos.get().into_iter().filter(|t| {
            match filter.get() {
                Filter::All => true,
                Filter::Active => !t.completed,
                Filter::Completed => t.completed,
            }
        }).collect::<Vec<_>>() {
            TodoItem { key: todo.id, label: todo.text.clone() }
        }
    }
}
}

Both todos and filter are tracked — the loop re-evaluates when either Signal changes. Any iterator chain that produces a Vec<T> (where T: Clone + PartialEq + 'static) works.

match

Write standard Rust match directly in RSX:

#![allow(unused)]
fn main() {
let tab = Signal::new(0);

rsx! {
    div {
        match tab.get() {
            0 => div { "Home page" },
            1 => div { "About page" },
            2 => div { "Settings page" },
            _ => div { "Page not found" },
        }
    }
}
}

Match with pattern bindings

Patterns that bind variables work — each arm re-evaluates the scrutinee to extract the bound values:

#![allow(unused)]
fn main() {
let result = Signal::new(Ok::<String, String>("Hello".into()));

rsx! {
    div {
        match result.get() {
            Ok(value) => p { "Success: " {value} },
            Err(msg) => p { class: "error", "Error: " {msg} },
        }
    }
}
}

Match with guards

Guard expressions are supported:

#![allow(unused)]
fn main() {
match score.get() {
    n if n >= 90 => div { "A" },
    n if n >= 80 => div { "B" },
    n if n >= 70 => div { "C" },
    _ => div { "F" },
}
}

How match works internally

The match block desugars to match_dom(), which generalizes show_dom() to N branches. A discriminant closure returns the index of the active branch (0, 1, 2, …). When the discriminant changes, the old branch is disposed and the new branch is rendered.

Programmatic Conditional Rendering (show_dom)

For cases requiring explicit control (e.g., lazy evaluation of children that contain hooks), use show_dom() directly:

#![allow(unused)]
fn main() {
show_dom(
    __scope,
    &parent,
    move || visible.get(),           // Condition closure
    |scope| {                        // Then branch
        let div = scope.create_element("div");
        div.set_text("Visible!");
        div
    },
    Some(|scope| {                   // Else branch (optional)
        let div = scope.create_element("div");
        div.set_text("Hidden");
        div
    }),
)
}

Programmatic List Rendering (for_each_dom_typed)

For cases requiring the raw list API, use for_each_dom_typed() directly:

#![allow(unused)]
fn main() {
for_each_dom_typed(
    __scope,
    &parent,
    move || todos.get().into_iter().collect::<Vec<_>>(),
    |todo| todo.id.to_string(),
    |todo, __child_scope| {
        let __scope = __child_scope;
        rsx! { div { {todo.name.clone()} } }
    },
)
}

How Keyed Reconciliation Works

When the list changes, for uses the LIS (Longest Increasing Subsequence) algorithm to find the minimum DOM operations:

#![allow(unused)]
fn main() {
// Before: ["a", "b", "c", "d"]
// After:  ["b", "e", "c", "a"]

// Operations:
// - Remove "d"
// - Insert "e" at index 1
// - Move "a" to index 3
// - Items "b" and "c" stay in place (part of LIS)
}

This means:

  • Unchanged items keep their DOM nodes (no re-render)
  • Moved items are repositioned in DOM (no re-create)
  • Changed items are re-rendered if their data differs (via PartialEq)
  • Internal state (signals, effects) is preserved for unchanged items
  • Only new items trigger component initialization

Note: The loop variable is owned (T, not &T), so you can capture it directly in move closures without manual field extraction.

Event Handlers

Events use the onevent: handler syntax:

#![allow(unused)]
fn main() {
// Inside a #[component] function:
let count = Signal::new(0);

rsx! {
    button {
        onclick: move || count.update(|n| *n += 1),
        "Increment"
    }
}
}

Sharing Event Handlers

You can extract event handlers into variables and reuse them across multiple elements. This is especially useful when a button and a text input should trigger the same action:

#![allow(unused)]
fn main() {
let input_text = Signal::new(String::new());
let todos = Signal::new(Vec::<String>::new());

// Extract shared logic into a closure
let add_todo = move || {
    let text = input_text.get();
    if !text.trim().is_empty() {
        todos.update(|t| t.push(text.trim().to_string()));
        input_text.set(String::new());
    }
};

rsx! {
    // Both TextInput (on Enter) and Button (on click) use the same handler
    TextInput {
        value_fn: move || input_text.get(),
        oninput: move |value: String| input_text.set(value),
        onsubmit: add_todo,
    }
    Button { onclick: add_todo, "Add" }
}
}

This works because Signal implements Copy, so closures capturing signals can be used in multiple places without .clone().

Style Shorthands

The rsx! macro supports CSS shorthand props on both HTML elements and components. Shorthands expand to set_style() calls that merge with existing styles.

Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}).

Available Shorthands

ShorthandCSS PropertyShorthandCSS Property
wwidthmmargin
hheightmtmargin-top
miwmin-widthmbmargin-bottom
mawmax-widthmlmargin-left
mihmin-heightmrmargin-right
mahmax-heightmxmargin-left + margin-right
ppaddingmymargin-top + margin-bottom
ptpadding-topdisplaydisplay
pbpadding-bottomposposition
plpadding-lefttoptop
prpadding-rightbottombottom
pxpadding-left + padding-rightleftleft
pypadding-top + padding-bottomrightright

Usage

#![allow(unused)]
fn main() {
// On HTML elements
div { p: "md", m: "lg", w: "200px", "Padded and margined" }

// On components
Stack { gap: "md", p: "xl", maw: "600px",
    Text { "Constrained content" }
}

// Reactive shorthands
let big = Signal::new(false);
div { p: {|| if big.get() { "xl" } else { "sm" }}, "Dynamic padding" }

// Spacing scale values auto-resolve
div { p: "md" }    // becomes: padding: var(--rinch-spacing-md)
div { p: "20px" }  // becomes: padding: 20px (passed through)
}

Application Order

Shorthands are applied via set_style() after component rendering and after the style: prop. This means shorthands win over conflicting properties in style:.

Styling

Inline styles and CSS classes work like regular HTML:

#![allow(unused)]
fn main() {
rsx! {
    html {
        head {
            style {
                "
                .container { padding: 20px; }
                .highlight { color: red; }
                "
            }
        }
        body {
            div { class: "container",
                span { class: "highlight", "Styled text" }
            }
        }
    }
}
}

State Management

There are no hooks. There is no re-rendering. Your component function runs once, builds the DOM, and closures keep it updated forever. Here’s how you manage state.

Core Primitives

PrimitiveWhat it does
Signal::new(value)Reactive state. Read it, write it, closures that read it re-run when it changes.
Memo::new(closure)Cached derived state. Like a Signal you can’t write to.
create_store(value)Share a store struct across components.
use_store::<T>()Access a shared store from any descendant.
create_context(value)Low-level shared state (mostly for framework internals).

For shared state, use stores. A store is a struct with Signal fields and methods that mutate them. Clean, testable, no prop drilling.

Signal

The foundational reactive primitive. Create one, read it in closures, and those closures re-run when the value changes.

#![allow(unused)]
fn main() {
let count = Signal::new(0);

count.get();                 // Read
count.set(5);                // Write
count.update(|n| *n += 1);  // Read-modify-write
}

Signal<T> is Copy. Use it in as many closures as you want — no .clone() needed.

#![allow(unused)]
fn main() {
#[component]
fn toggle() -> NodeHandle {
    let on = Signal::new(false);

    rsx! {
        button { onclick: move || on.update(|b| *b = !*b),
            {|| if on.get() { "ON" } else { "OFF" }}
        }
    }
}
}

Cross-Thread Dispatch

Signal::set() and Signal::update() must be called from the main thread. From a background thread, use send() and update_send():

#![allow(unused)]
fn main() {
let progress = Signal::new(0);

std::thread::spawn(move || {
    for i in 0..100 {
        std::thread::sleep(Duration::from_millis(50));
        progress.send(i);  // Dispatches to main thread automatically
    }
});
}

For more details, see Signals.

Memo

Cached derived state that recomputes only when its dependencies change.

#![allow(unused)]
fn main() {
let first = Signal::new("Alice".to_string());
let last = Signal::new("Smith".to_string());

let full = Memo::new(move || format!("{} {}", first.get(), last.get()));
// full.get() recomputes only when first or last change
}

Memo<T> is also Copy. Dependencies are tracked automatically — no dependency arrays.

For more details, see Memos.

Effect (Advanced)

Most reactive DOM updates happen through {|| expr} closures in RSX. For the rare case where you need to react to signal changes outside of the DOM (logging, syncing to an external system), use Effect:

#![allow(unused)]
fn main() {
use rinch::reactive::Effect;

let count = Signal::new(0);

Effect::new(move || {
    println!("Count is now: {}", count.get());
});
}

Effect is intentionally excluded from the prelude. If you’re reaching for it, ask yourself: can this be a closure in RSX, or a method on a store? Usually the answer is yes.

For more details, see Effects.

Context

Share state across components without prop drilling. The ancestor creates it, any descendant reads it.

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppConfig {
    api_url: String,
}

#[component]
fn app() -> NodeHandle {
    create_context(AppConfig { api_url: "https://api.example.com".into() });
    rsx! { div { ChildComponent {} } }
}

#[component]
fn ChildComponent() -> NodeHandle {
    let config = use_context::<AppConfig>();
    rsx! { Text { {config.api_url.clone()} } }
}
}

use_context::<T>() panics if the context is missing (with a helpful message). Use try_use_context::<T>() for an Option<T> instead.

For reactive shared state, use stores — they’re contexts with Signal fields and methods.

Putting It Together

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    let todos = Signal::new(vec!["Learn Rinch".to_string()]);
    let input = Signal::new(String::new());
    let count = Memo::new(move || todos.get().len());

    let add = move || {
        let text = input.get();
        if !text.is_empty() {
            todos.update(|t| t.push(text.clone()));
            input.set(String::new());
        }
    };

    rsx! {
        Stack { gap: "md", p: "xl",
            Title { order: 1, "Todos (" {|| count.get().to_string()} ")" }

            Group { gap: "sm",
                TextInput {
                    placeholder: "What needs doing?",
                    value_fn: move || input.get(),
                    oninput: move |v: String| input.set(v),
                    onsubmit: add,
                }
                Button { onclick: add, "Add" }
            }

            for todo in todos.get() {
                div { key: todo.clone(), {todo.clone()} }
            }
        }
    }
}
}

Signal is Copy, so add captures todos and input without ceremony. count is a Memo that recomputes when todos changes. The for loop is reactive — add or remove a todo and only the affected DOM nodes change.

Signals

A Signal holds a value and tells everyone who cares when it changes. Signals are the foundation of everything reactive in Rinch.

Creating Signals

#![allow(unused)]
fn main() {
let count = Signal::new(0);
let name = Signal::new(String::from("Alice"));
let items = Signal::new(vec![1, 2, 3]);
}

Signals can hold any 'static type. They’re cheap — just an index into a global slot map.

Signal is Copy

This is the single most important thing about Rinch’s reactive system. Signal<T> implements Copy. You never need .clone() before passing a signal into a closure:

#![allow(unused)]
fn main() {
let count = Signal::new(0);

// Use count in as many closures as you want — it's Copy
Effect::new(move || println!("count = {}", count.get()));
rsx! {
    button { onclick: move || count.update(|n| *n += 1), "+" }
    span { {|| count.get().to_string()} }
}
}

Same for Memo<T> — also Copy.

Reading Values

.get() — Clone and Return

#![allow(unused)]
fn main() {
let count = Signal::new(0);
let value = count.get(); // Returns 0 (cloned)
}

Inside an Effect or Memo, calling .get() automatically subscribes to the signal. When the signal changes, the Effect re-runs. No dependency arrays. No manual tracking.

.with() — Access by Reference

For types that are expensive to clone:

#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3, 4, 5]);

let length = items.with(|v| v.len());
let first = items.with(|v| v.first().copied());
}

Writing Values

.set() — Replace

#![allow(unused)]
fn main() {
count.set(5); // Notifies all subscribers
}

.update() — Modify in Place

#![allow(unused)]
fn main() {
count.update(|n| *n += 1);
items.update(|v| v.push(4));
}

Automatic Dependency Tracking

Read a signal inside an Effect or Memo and the dependency is tracked automatically:

#![allow(unused)]
fn main() {
let first_name = Signal::new("Alice".to_string());
let last_name = Signal::new("Smith".to_string());

// This effect depends on BOTH signals — discovered at runtime
Effect::new(move || {
    println!("{} {}", first_name.get(), last_name.get());
});

first_name.set("Bob".to_string());   // Effect re-runs
last_name.set("Jones".to_string());  // Effect re-runs
}

Dependencies are dynamic. If a signal is only read conditionally, the subscription only exists when that branch executes.

Cross-Thread Dispatch

set() and update() panic if called from a non-main thread (with a helpful message). For background threads, use send() and update_send():

#![allow(unused)]
fn main() {
let progress = Signal::new(0);

std::thread::spawn(move || {
    for i in 0..100 {
        std::thread::sleep(std::time::Duration::from_millis(50));
        progress.send(i);  // Dispatches to main thread
    }
});
}

send() requires T: Send. update_send() requires the closure to be Send + 'static.

Best Practices

Keep signals focused. One signal per concern, not one giant state bag:

#![allow(unused)]
fn main() {
// Good
let name = Signal::new(String::new());
let age = Signal::new(0);

// Less good — changing name re-runs everything that reads age too
let state = Signal::new(AppState { name, age });
}

Read once before loops:

#![allow(unused)]
fn main() {
// Read once, loop over the snapshot
let val = items.get();
for item in &val {
    // use item
}
}

Use {|| expr} in RSX, not raw Effects. The RSX closure syntax is the right tool for DOM updates. Reserve Effect::new() for side effects outside the DOM (logging, network calls, syncing external state).

Effects

An Effect is a side-effect that runs when its dependencies change. It tracks which signals it reads and re-runs when any of them update. No dependency arrays, no manual subscription — just read a signal and you’re subscribed.

You probably don’t need this. For reactive DOM updates, use {|| expr} closures in RSX. For state mutations, use store methods. Effect is for the rare case where you need to react to signal changes outside of both — logging, syncing to external systems, etc.

Effect is intentionally excluded from the prelude. Import explicitly:

#![allow(unused)]
fn main() {
use rinch::reactive::Effect;
}

Creating Effects

#![allow(unused)]
fn main() {
let count = Signal::new(0);

// Runs immediately, then re-runs when count changes
Effect::new(move || {
    println!("Count is: {}", count.get());
});

count.set(1); // Prints: "Count is: 1"
count.set(2); // Prints: "Count is: 2"
}

Signal is Copy, so you can use count in the closure and still use it elsewhere. No .clone().

When Effects Run

  1. Immediately on creation — the effect runs once right away
  2. When dependencies change — whenever a tracked signal is updated
#![allow(unused)]
fn main() {
let a = Signal::new(1);
let b = Signal::new(2);

Effect::new(move || {
    println!("a = {}, b = {}", a.get(), b.get());
});
// Prints: "a = 1, b = 2"

a.set(10); // Prints: "a = 10, b = 2"
b.set(20); // Prints: "a = 10, b = 20"
}

Conditional Dependencies

Dependencies are discovered at runtime, not declared statically. If a signal is only read in one branch, the subscription only exists when that branch executes:

#![allow(unused)]
fn main() {
let show_details = Signal::new(false);
let name = Signal::new("Alice".to_string());
let age = Signal::new(30);

Effect::new(move || {
    println!("Name: {}", name.get());
    if show_details.get() {
        println!("Age: {}", age.get()); // only tracked when show_details is true
    }
});

age.set(31);           // Nothing happens — age isn't tracked yet
show_details.set(true); // Re-runs, now age IS tracked
age.set(32);           // Re-runs — age is tracked now
}

Common Patterns

Logging

#![allow(unused)]
fn main() {
let count = Signal::new(0);
Effect::new(move || {
    println!("[DEBUG] count = {}", count.get());
});
}

Syncing to External Systems

#![allow(unused)]
fn main() {
let theme = Signal::new("light".to_string());
Effect::new(move || {
    update_css_theme(&theme.get());
});
}

Derived Side Effects

#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3]);
Effect::new(move || {
    let count = items.with(|v| v.len());
    update_badge(count);
});
}

Disposing Effects

#![allow(unused)]
fn main() {
let effect = Effect::new(move || {
    println!("Count: {}", count.get());
});

effect.dispose(); // Effect stops running
count.set(1);     // Nothing happens
}

Pitfalls

Don’t create effects inside effects. The inner effect gets recreated every time the outer one re-runs.

Don’t modify a signal you read in the same effect. That’s an infinite loop. Use untracked(|| signal.get()) if you need to read without subscribing.

#![allow(unused)]
fn main() {
// BAD: infinite loop
Effect::new(move || {
    let val = count.get();
    count.set(val + 1); // triggers re-run -> reads count -> triggers re-run -> ...
});

// OK: read without subscribing
Effect::new(move || {
    let val = untracked(|| count.get());
    do_something(val);
});
}

Memos

A Memo is a cached computed value that only recomputes when its dependencies change. Think of it as a signal you can’t write to — it derives its value from other signals.

Creating Memos

#![allow(unused)]
fn main() {
let count = Signal::new(2);

let doubled = Memo::new(move || count.get() * 2);

doubled.get(); // 4
count.set(3);
doubled.get(); // 6 (recomputed)
doubled.get(); // 6 (cached — no recomputation)
}

Memo<T> is Copy, just like Signal<T>. Use it in multiple closures without .clone().

How Memos Work

  1. Lazy — only computes when you call .get()
  2. Cached — returns the cached result if dependencies haven’t changed
  3. Tracked — automatically discovers which signals it reads
  4. Composable — memos can depend on other memos
Signal(count) ──► Memo(doubled) ──► cached value
    │                   │
  .set(3)            .get()
    │                   │
    ▼                   ▼
 marks memo       recomputes if
 as "dirty"          dirty

Memos vs Effects

MemoEffect
Returns a valueYesNo
Runs eagerlyNo (lazy)Yes (immediate)
PurposeDerived stateSide effects
Caches resultYesN/A

Use a Memo when you need a computed value. Use an Effect when you need to perform an action.

Chaining Memos

Memos can depend on other memos:

#![allow(unused)]
fn main() {
let count = Signal::new(2);
let doubled = Memo::new(move || count.get() * 2);
let quadrupled = Memo::new(move || doubled.get() * 2);

assert_eq!(quadrupled.get(), 8);
count.set(3);
assert_eq!(quadrupled.get(), 12);
}

The derived() Helper

Syntactic sugar for Memo::new():

#![allow(unused)]
fn main() {
let count = Signal::new(5);

// These are equivalent:
let doubled = Memo::new(move || count.get() * 2);
let doubled = derived(move || count.get() * 2);
}

Common Patterns

Computed Strings

#![allow(unused)]
fn main() {
let first = Signal::new("Alice".to_string());
let last = Signal::new("Smith".to_string());

let full = Memo::new(move || format!("{} {}", first.get(), last.get()));
}

Filtering Lists

#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3, 4, 5, 6]);
let show_even = Signal::new(true);

let filtered = Memo::new(move || {
    let all = items.get();
    if show_even.get() {
        all.into_iter().filter(|n| n % 2 == 0).collect()
    } else {
        all
    }
});
}

Expensive Computations

#![allow(unused)]
fn main() {
let data = Signal::new(large_dataset);

// Only recomputes when data changes, no matter how many times you .get()
let analysis = Memo::new(move || {
    data.with(|d| perform_analysis(d))
});
}

Memos in RSX

Memos work in reactive closures exactly like signals:

#![allow(unused)]
fn main() {
let count = Signal::new(0);
let doubled = Memo::new(move || count.get() * 2);

rsx! {
    p { "Count: " {|| count.get().to_string()} }
    p { "Doubled: " {|| doubled.get().to_string()} }
}
}

Both count and doubled are Copy. Both create subscriptions when read in {|| ...} closures. The doubled text node updates only when the memo’s value actually changes.

State Management with Stores

Stores are the recommended way to manage shared state in rinch. A store is a plain struct with Signal fields and methods that encapsulate state mutations.

The Store Pattern

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[derive(Clone, Copy)]
struct CounterStore {
    count: Signal<i32>,
}

impl CounterStore {
    fn new() -> Self {
        Self { count: Signal::new(0) }
    }

    fn increment(&self) {
        self.count.update(|n| *n += 1);
    }

    fn decrement(&self) {
        self.count.update(|n| *n -= 1);
    }

    fn reset(&self) {
        self.count.set(0);
    }
}
}

Why this works well:

  • State and mutations live together — no scattered signal updates across components
  • Signal<T> is Copy, so the store struct is Copy when all fields are signals
  • Methods are the single source of truth for how state changes
  • Easy to test, easy to reason about

Providing and Consuming Stores

Call create_store() in a parent component to make the store available to all descendants. Call use_store::<T>() in any descendant to retrieve it.

#![allow(unused)]
fn main() {
#[component]
fn app() -> NodeHandle {
    create_store(CounterStore::new());

    rsx! {
        div {
            CounterDisplay {}
            CounterControls {}
        }
    }
}

#[component]
fn counter_display() -> NodeHandle {
    let store = use_store::<CounterStore>();

    rsx! {
        p { "Count: " {|| store.count.get().to_string()} }
    }
}

#[component]
fn counter_controls() -> NodeHandle {
    let store = use_store::<CounterStore>();

    rsx! {
        div {
            button { onclick: move || store.decrement(), "-" }
            button { onclick: move || store.reset(), "Reset" }
            button { onclick: move || store.increment(), "+" }
        }
    }
}
}

use_store::<T>() panics with a helpful message if no store of that type exists:

Store not found: CounterStore
Did you forget to call create_store() in a parent component?

Use try_use_store::<T>() when a store may legitimately be absent — it returns Option<T>.

A Larger Example: Todo Store

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[derive(Clone, PartialEq)]
struct Todo {
    id: u32,
    text: String,
    done: bool,
}

#[derive(Clone, Copy)]
struct TodoStore {
    todos: Signal<Vec<Todo>>,
    next_id: Signal<u32>,
    filter: Signal<Filter>,
}

#[derive(Clone, Copy, PartialEq)]
enum Filter { All, Active, Completed }

impl TodoStore {
    fn new() -> Self {
        Self {
            todos: Signal::new(Vec::new()),
            next_id: Signal::new(1),
            filter: Signal::new(Filter::All),
        }
    }

    fn add(&self, text: String) {
        let id = self.next_id.get();
        self.next_id.set(id + 1);
        self.todos.update(|t| t.push(Todo { id, text, done: false }));
    }

    fn toggle(&self, id: u32) {
        self.todos.update(|todos| {
            if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
                todo.done = !todo.done;
            }
        });
    }

    fn remove(&self, id: u32) {
        self.todos.update(|t| t.retain(|todo| todo.id != id));
    }

    fn visible_todos(&self) -> Vec<Todo> {
        let todos = self.todos.get();
        match self.filter.get() {
            Filter::All => todos,
            Filter::Active => todos.into_iter().filter(|t| !t.done).collect(),
            Filter::Completed => todos.into_iter().filter(|t| t.done).collect(),
        }
    }

    fn remaining_count(&self) -> usize {
        self.todos.with(|t| t.iter().filter(|t| !t.done).count())
    }
}
}

Components consume the store and call its methods:

#![allow(unused)]
fn main() {
#[component]
fn todo_app() -> NodeHandle {
    create_store(TodoStore::new());
    let store = use_store::<TodoStore>();
    let input = Signal::new(String::new());

    rsx! {
        div {
            TextInput {
                placeholder: "What needs to be done?",
                value_fn: move || input.get(),
                oninput: move |val: String| input.set(val),
                onsubmit: move || {
                    let text = input.get();
                    if !text.is_empty() {
                        store.add(text);
                        input.set(String::new());
                    }
                },
            }

            p { {|| format!("{} items left", store.remaining_count())} }

            for todo in store.visible_todos() {
                div { key: todo.id,
                    Checkbox {
                        label: todo.text.clone(),
                        checked_fn: {
                            let done = todo.done;
                            move || done
                        },
                        on_change: {
                            let id = todo.id;
                            move || store.toggle(id)
                        },
                    }
                    button {
                        onclick: { let id = todo.id; move || store.remove(id) },
                        "Delete"
                    }
                }
            }
        }
    }
}
}

Side Effects Belong in Store Methods

Keep side effects (logging, persistence, validation) inside store methods rather than scattered across components:

#![allow(unused)]
fn main() {
impl TodoStore {
    fn add(&self, text: String) {
        let id = self.next_id.get();
        self.next_id.set(id + 1);
        self.todos.update(|t| t.push(Todo { id, text: text.clone(), done: false }));

        // Side effect lives here, not in 5 different components
        println!("Added todo #{id}: {text}");
    }
}
}

This keeps components focused on rendering and event handling.

When You Don’t Need a Store

For component-local state that no other component needs, just use Signal::new() directly:

#![allow(unused)]
fn main() {
#[component]
fn toggle_button() -> NodeHandle {
    let expanded = Signal::new(false);

    rsx! {
        button {
            onclick: move || expanded.update(|v| *v = !*v),
            {|| if expanded.get() { "Collapse" } else { "Expand" }}
        }
    }
}
}

No store needed — expanded is only used by this one component.

Use a store when:

  • Multiple components need to read or write the same state
  • You want to centralize mutation logic (validation, side effects)
  • State has complex update rules that benefit from named methods

Use a plain Signal when:

  • State is local to a single component (toggles, input text, hover state)
  • No other component cares about this value

Advanced: Explicit Effects

Most reactive DOM updates use {|| expr} closures in rsx — you rarely need Effect directly. For advanced cases like syncing state to an external system, import it explicitly:

#![allow(unused)]
fn main() {
use rinch::reactive::Effect;

let count = Signal::new(0);

// Sync to an external system whenever count changes
Effect::new(move || {
    update_external_dashboard(count.get());
});
}

You probably don’t need Effect. If you’re updating the DOM, use {|| expr} in rsx. If you’re updating state, put the logic in a store method. Effect is for the rare case where you need to react to signal changes outside of both rendering and user actions.

Sharing State

There are three ways to share state between components in Rinch: stores (recommended), lifting state up (passing signals as props), and context (low-level implicit sharing).

Recommended: For most shared state, use the store pattern — a struct with Signal fields shared via create_store() / use_store().

See the full Stores guide. Quick summary:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct AppStore {
    count: Signal<i32>,
}

impl AppStore {
    fn new() -> Self { Self { count: Signal::new(0) } }
    fn increment(&self) { self.count.update(|n| *n += 1); }
}

#[component]
fn app() -> NodeHandle {
    create_store(AppStore::new());
    rsx! { div { Counter {} } }
}

#[component]
fn counter() -> NodeHandle {
    let store = use_store::<AppStore>();
    rsx! {
        p { {|| store.count.get().to_string()} }
        button { onclick: move || store.increment(), "+" }
    }
}
}

Lifting State Up

The simplest approach — move state to the nearest common ancestor and pass it down as props.

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
pub fn Counter(count: Signal<i32>, children: &[NodeHandle]) -> NodeHandle {
    rsx! {
        div {
            p { "Count: " {|| count.get().to_string()} }
            {children}
        }
    }
}

#[component]
fn app() -> NodeHandle {
    // State lives here, shared with both children
    let count = Signal::new(0);

    rsx! {
        div {
            Counter { count,
                button { onclick: move || count.update(|n| *n += 1), "+" }
                button { onclick: move || count.update(|n| *n -= 1), "-" }
            }
        }
    }
}
}

Signal<T> implements Copy, so you can pass it to multiple children without .clone().

When to use: When only a small number of nearby components need the same state.


Context

Context lets you share state without explicitly threading it through props. Call create_context() in an ancestor component, then use_context::<T>() in any descendant.

Creating Context

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct Theme {
    primary: String,
    dark_mode: bool,
}

#[component]
fn app() -> NodeHandle {
    create_context(Theme {
        primary: "#007bff".into(),
        dark_mode: false,
    });

    rsx! {
        div {
            // All descendants can access Theme
            Toolbar {}
            MainContent {}
        }
    }
}
}

Consuming Context

use_context::<T>() returns T directly. It panics with a helpful message if the context is absent:

Context not found: Theme
Did you forget to call create_context() in a parent component?
#![allow(unused)]
fn main() {
#[component]
fn toolbar() -> NodeHandle {
    let theme = use_context::<Theme>();

    rsx! {
        nav { style: {|| format!("background: {}", theme.primary)},
            "Toolbar"
        }
    }
}
}

Fallible Access

Use try_use_context::<T>() when a context may legitimately be absent — it returns Option<T> instead of panicking:

#![allow(unused)]
fn main() {
#[component]
fn themed_button() -> NodeHandle {
    let theme = try_use_context::<Theme>();
    let bg = theme.map(|t| t.primary).unwrap_or("#ccc".into());

    rsx! {
        button { style: {|| format!("background: {}", bg)},
            "Click me"
        }
    }
}
}

When to use: When a component can render sensibly with or without the context (e.g., a component used both inside and outside a theme provider).


Reactive Context with Signals

For context that changes over time, store a Signal<T> in context so descendants can both read and update it:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct AppState {
    dark_mode: Signal<bool>,
    user_name: Signal<String>,
}

#[component]
fn app() -> NodeHandle {
    create_context(AppState {
        dark_mode: Signal::new(false),
        user_name: Signal::new("Guest".into()),
    });

    rsx! {
        div {
            Header {}
            MainContent {}
        }
    }
}

#[component]
fn header() -> NodeHandle {
    let state = use_context::<AppState>();

    rsx! {
        header {
            span { {|| state.user_name.get()} }
            button {
                onclick: move || state.dark_mode.update(|v| *v = !*v),
                {|| if state.dark_mode.get() { "Light mode" } else { "Dark mode" }}
            }
        }
    }
}
}

Because Signal<T> is Copy, the AppState struct itself is Copy when all its fields are signals, making it cheap to access from any descendant.


Which Approach to Use?

SituationApproach
Shared state with action methodsStore (create_store / use_store)
1–2 nearby componentsLift state up (pass signals as props)
Framework-internal shared stateContext (create_context / use_context)
Optional / may not always be providedtry_use_store or try_use_context
Static configuration (theme colors, locale)Plain value in context

Writing Your Own Components

You have two options. The first one is almost always what you want.

Option 1: PascalCase Component Functions

Write a function with a PascalCase name and #[component]. The macro generates a struct, a Default impl, and a Component trait impl. Parameters become props. Done.

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
pub fn UserCard(
    name: String,
    email: String,
    avatar_url: String,
    online: bool,
    onclick: Option<Callback>,
) -> NodeHandle {
    rsx! {
        Paper { shadow: "sm", p: "md",
            Group { gap: "md",
                Avatar { src: avatar_url.clone(), size: "lg" }
                Stack { gap: "xs",
                    Text { fw: "bold", {name.clone()} }
                    Text { size: "sm", color: "dimmed", {email.clone()} }
                }
                if online {
                    Badge { color: "green", variant: "dot", "Online" }
                }
            }
        }
    }
}
}

Use it:

#![allow(unused)]
fn main() {
rsx! {
    UserCard {
        name: "Alice",
        email: "alice@example.com",
        avatar_url: "/avatars/alice.png",
        online: true,
        onclick: || println!("clicked!"),
    }
}
}

The Rules

Prop types must be owned. String, not &str. Vec<T>, not &[T]. The macro will give you a clear error if you use a reference type.

The macro wraps things for you. Don’t fight it:

What you writeWhat the macro generates
onclick: || do_thing()Callback::new(|| do_thing()).into()
oninput: move |v: String| ...InputCallback::new(move |v| ...).into()
icon: Icon::CheckSome(Icon::Check)
variant: "filled"String::from("filled")
disabled: truetrue (no wrapping)
size: 42Some(42)
value_fn: move || text.get()Some(Rc::new(move || text.get()))

Don’t manually wrap. Writing onclick: Some(Callback::new(|| ...)) double-wraps and you’ll get confusing type errors.

children: &[NodeHandle] is special. It’s not a struct field — it captures child elements passed in RSX:

#![allow(unused)]
fn main() {
#[component]
pub fn Card(title: String, children: &[NodeHandle]) -> NodeHandle {
    rsx! {
        Paper { shadow: "sm", p: "md",
            Title { order: 3, {title.clone()} }
            // children are automatically appended
        }
    }
}

// Usage:
rsx! {
    Card { title: "My Card",
        Text { "This is a child" }
        Button { "So is this" }
    }
}
}

Option 2: Manual Component Trait

For full control, implement Component directly:

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[derive(Debug, Default)]
pub struct Countdown {
    pub from: i32,
}

impl Component for Countdown {
    fn render(&self, scope: &mut RenderScope, _children: &[NodeHandle]) -> NodeHandle {
        let remaining = Signal::new(self.from);

        // rsx! works inside Component::render too — __scope is just `scope` here
        let __scope = scope;
        rsx! {
            div {
                Text { size: "xl", {|| remaining.get().to_string()} }
                Button {
                    disabled: {|| remaining.get() <= 0},
                    onclick: move || remaining.update(|n| *n -= 1),
                    "Count down"
                }
            }
        }
    }
}
}

You probably don’t need this. The PascalCase function covers 95% of use cases. But it’s there for when you need custom Debug, or want to store state on the struct itself, or are doing something the macro can’t express.

Lowercase Component Functions

Not everything needs to be a reusable component with props. For private helper functions, use a lowercase name:

#![allow(unused)]
fn main() {
#[component]
fn sidebar_link(label: &str, active: bool) -> NodeHandle {
    rsx! {
        div { class: {|| if active { "nav-link active" } else { "nav-link" }},
            {label}
        }
    }
}
}

Lowercase functions get __scope injected but don’t generate a struct. Call them directly:

#![allow(unused)]
fn main() {
rsx! {
    div {
        {sidebar_link(__scope, "Home", true)}
        {sidebar_link(__scope, "Settings", false)}
    }
}
}

Making Props Reactive

Any component prop accepts a closure {|| expr} for reactive updates:

#![allow(unused)]
fn main() {
let active = Signal::new(false);
rsx! {
    Button {
        variant: {|| if active.get() { "filled" } else { "outline" }},
        onclick: move || active.update(|v| *v = !*v),
        "Toggle"
    }
}
}

When the signal changes, the component re-renders with the new prop value. For the built-in components, style: and class: props get surgical DOM updates without re-rendering the component.

_fn Props for Surgical Updates

Some components offer _fn suffix props that bypass full re-render:

#![allow(unused)]
fn main() {
let text = Signal::new(String::new());
rsx! {
    TextInput {
        value_fn: move || text.get(),           // Updates the DOM input value directly
        oninput: move |v: String| text.set(v),  // Feeds changes back to the signal
    }
}
}

value_fn updates the input’s value attribute without re-rendering the entire TextInput component. Use this for controlled inputs — without it, programmatic signal.set("") won’t clear the input visually.

Style and Class on Components

All components support style: and class: props:

#![allow(unused)]
fn main() {
Button {
    variant: "filled",
    style: "margin-top: 8px",
    class: "my-custom-button",
    onclick: || do_something(),
    "Click me"
}
}

class: is additive — it merges with the component’s own CSS classes, it doesn’t replace them. Both accept reactive closures:

#![allow(unused)]
fn main() {
Text {
    style: {|| if highlighted.get() { "background: yellow" } else { "" }},
    "Dynamic styling"
}
}

CSS Shorthand Props

All HTML elements and components support CSS shorthand props. These expand to set_style() calls:

#![allow(unused)]
fn main() {
rsx! {
    div { p: "md", m: "sm", maw: "600px",    // padding, margin, max-width
        Stack { w: "100%", h: "auto",          // width, height
            Text { px: "lg", my: "xs", "Spaced text" }  // padding-x, margin-y
        }
    }
}
}

Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}).

Components

Rinch provides a comprehensive component library with 60+ styled, themeable UI components inspired by Mantine. Enable with the components feature (which also enables theme):

[dependencies]
rinch = { workspace = true, features = ["desktop", "components", "theme"] }

Note: The workspace dependency uses default-features = false, so features must be listed explicitly:

  • "desktop" — enables run(), window management, and the software renderer (tiny-skia)
  • "gpu" — (optional) enables GPU rendering via Vello/wgpu instead of the software renderer
  • "components" — enables the component library (Button, TextInput, Stack, etc.)
  • "theme" — enables automatic theme CSS loading and CSS variables

Using Components

Components are used directly in RSX with a declarative syntax:

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        Stack { gap: "md",
            Title { order: 1, "Welcome" }
            Text { size: "lg", color: "dimmed", "Get started with Rinch components." }

            Group { gap: "sm",
                Button { variant: "filled", "Get Started" }
                Button { variant: "outline", "Learn More" }
            }
        }
    }
}

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("cyan".into()),
        ..Default::default()
    };
    run_with_theme("My App", 800, 600, app, theme);
}

Component Categories

Layout Components

ComponentDescription
StackVertical flex container with spacing
GroupHorizontal flex container with spacing
ContainerCentered max-width container
CenterCenter content horizontally/vertically
SpaceEmpty space component
#![allow(unused)]
fn main() {
// Stack - vertical layout
Stack { gap: "md",
    Text { "Item 1" }
    Text { "Item 2" }
}

// Group - horizontal layout
Group { gap: "sm", justify: "space-between",
    Button { "Left" }
    Button { "Right" }
}

// Center content
Center {
    Loader {}
}

// Add spacing
Space { h: "xl" }  // height
Space { w: "md" }  // width
}

Buttons

ComponentDescription
ButtonClickable button with variants
ActionIconIcon-only button
CloseButtonDismiss/close button
#![allow(unused)]
fn main() {
// Button variants
Button { variant: "filled", "Primary" }
Button { variant: "outline", "Secondary" }
Button { variant: "light", "Light" }
Button { variant: "subtle", "Subtle" }

// Button sizes
Button { size: "xs", "Extra Small" }
Button { size: "sm", "Small" }
Button { size: "md", "Medium" }  // default
Button { size: "lg", "Large" }
Button { size: "xl", "Extra Large" }

// Button colors
Button { color: "red", "Delete" }
Button { color: "green", "Success" }

// Button states
Button { disabled: true, "Disabled" }
Button { loading: true, "Loading..." }
Button { full_width: true, "Full Width" }

// With click handler
Button { onclick: move || count.update(|n| *n += 1), "Click Me" }

// ActionIcon - for icon buttons
ActionIcon { variant: "filled", "+" }
ActionIcon { size: "lg", variant: "light", "?" }

// CloseButton
CloseButton { onclick: move || modal_open.set(false) }
}

Form Inputs

ComponentDescription
TextInputSingle-line text input
TextareaMulti-line text input
PasswordInputPassword field with visibility toggle
NumberInputNumeric input with controls
CheckboxCheckbox input
SwitchToggle switch
SelectDropdown select
Radio / RadioGroupRadio buttons
SliderRange input slider
FieldsetGrouped form fields
#![allow(unused)]
fn main() {
// TextInput with label and description
TextInput {
    label: "Email",
    placeholder: "you@example.com",
    description: "We'll never share your email."
}

// TextInput with error
TextInput {
    label: "Password",
    input_type: "password",
    error: "Password must be at least 8 characters"
}

// Controlled input (value_fn + oninput)
let input_text = Signal::new(String::new());
TextInput {
    placeholder: "Type here...",
    value_fn: move || input_text.get(),
    oninput: move |value: String| input_text.set(value),
}

// TextInput with onsubmit (fires on Enter key)
let search = Signal::new(String::new());
TextInput {
    placeholder: "Search...",
    value_fn: move || search.get(),
    oninput: move |value: String| search.set(value),
    onsubmit: move || println!("Searching: {}", search.get()),
}

// PasswordInput with visibility toggle
PasswordInput {
    label: "Password",
    placeholder: "Enter password"
}

// NumberInput
NumberInput {
    label: "Quantity",
    placeholder: "0"
}

// Textarea
Textarea {
    label: "Description",
    placeholder: "Enter your message...",
    min_rows: 4          // visible rows; defaults to 2
}

// Checkbox
let checked = Signal::new(false);
Checkbox {
    label: "Accept terms",
    checked: checked.get(),
    onchange: move || checked.update(|v| *v = !*v)
}

// Switch
Switch {
    label: "Enable notifications",
    checked: enabled.get(),
    onchange: move || enabled.update(|v| *v = !*v)
}

// Select
Select {
    label: "Country",
    placeholder: "Select a country",
    option { value: "us", "United States" }
    option { value: "uk", "United Kingdom" }
    option { value: "ca", "Canada" }
}

// RadioGroup
RadioGroup { label: "Plan",
    Radio { value: "free", label: "Free" }
    Radio { value: "pro", label: "Pro" }
    Radio { value: "enterprise", label: "Enterprise" }
}

// Slider
Slider { color: "cyan" }

// Fieldset
Fieldset { legend: "Personal Info",
    Stack { gap: "sm",
        TextInput { placeholder: "First name" }
        TextInput { placeholder: "Last name" }
    }
}
}

Typography

ComponentDescription
TextText display with styling
TitleHeading text (h1-h6)
CodeInline or block code
KbdKeyboard key
AnchorStyled link
BlockquoteStyled quotation
MarkHighlighted text
HighlightSearch text highlighting
// Text sizes and weights
Text { size: "lg", weight: "bold", "Large Bold Text" }
Text { size: "sm", color: "dimmed", "Small dimmed text" }

// Titles (h1-h6)
Title { order: 1, "Main Heading" }
Title { order: 2, "Subheading" }
Title { order: 3, "Section Title" }

// Code
Text { "Run " Code { "npm install" } " to install." }
Code { block: true, "fn main() {\n    println!(\"Hello!\");\n}" }

// Keyboard keys
Group { gap: "xs",
    Kbd { "Ctrl" }
    Text { "+" }
    Kbd { "C" }
}

// Anchor (link)
Anchor { href: "https://example.com", "Visit site" }
Anchor { href: "#", underline: true, "Always underlined" }

// Blockquote
Blockquote { cite: "— Albert Einstein",
    "Imagination is more important than knowledge."
}

// Mark (highlight)
Text { "This has " Mark { "marked text" } " inline." }

// Highlight (search highlighting)
Highlight { highlight: "quick brown", "The quick brown fox jumps" }

Feedback

ComponentDescription
AlertUser feedback messages
LoaderLoading spinner/indicator
ProgressProgress bar
SkeletonLoading placeholder
TooltipCSS-only hover tooltip
#![allow(unused)]
fn main() {
// Alert variants
Alert { color: "blue", title: "Info", "This is informational." }
Alert { color: "green", title: "Success", "Operation completed!" }
Alert { color: "yellow", title: "Warning", "Please review." }
Alert { color: "red", title: "Error", "Something went wrong." }

Alert { variant: "filled", color: "blue", "Filled style" }
Alert { variant: "light", color: "blue", "Light style" }
Alert { variant: "outline", color: "blue", "Outline style" }

// Loader
Loader {}
Loader { size: "lg", color: "cyan" }

// Progress
Progress { color: "blue" }
Progress { color: "green", striped: true }
Progress { striped: true, animated: true }

// Skeleton (loading placeholders)
Skeleton { height: "12px", width: "80%" }
Skeleton { height: "40px", width: "40px", circle: true }

// Tooltip
Tooltip { label: "This is a tooltip!",
    Button { "Hover me" }
}
Tooltip { label: "Top tooltip", position: "top",
    Button { "Top" }
}
}

Data Display

ComponentDescription
AvatarUser avatar with image or initials
BadgeSmall status indicator
Card / CardSectionContainer with sections
PaperCard-like container with shadow
DividerHorizontal/vertical separator
ImageResponsive image with fallback
List / ListItemStyled list
#![allow(unused)]
fn main() {
// Avatar
Avatar { name: "John Doe" }  // Shows "JD"
Avatar { size: "lg", color: "cyan", name: "Jane Smith" }
Avatar { src: "https://example.com/photo.jpg" }

// Badge
Badge { "New" }
Badge { variant: "light", color: "green", "Active" }
Badge { variant: "outline", color: "red", "Error" }
Badge { variant: "dot", "With dot" }

// Card
Card { shadow: "sm",
    CardSection { with_border: true,
        div { style: "padding: var(--rinch-spacing-sm);",
            Text { weight: "bold", "Card Title" }
        }
    }
    div { style: "padding: var(--rinch-spacing-md);",
        Text { "Card content goes here." }
    }
}

// Paper
Paper { shadow: "md", p: "lg",
    "Simple paper container"
}
Paper { shadow: "sm", p: "md", with_border: true,
    "Paper with border"
}

// Divider
Divider {}
Divider { label: "OR" }

// Image (local file or URL with image-network feature)
Image {
    src: "photo.png",
    width: "200",
    height: "150",
    fit: "cover",
    radius: "md",
}

// Avatar with image
Avatar { src: "photo.png", size: "lg" }

// List
List {
    ListItem { "First item" }
    ListItem { "Second item" }
    ListItem { "Third item" }
}
}
ComponentDescription
Tabs / TabsList / Tab / TabsPanelTab navigation
Accordion / AccordionItem / AccordionControl / AccordionPanelCollapsible sections
Breadcrumbs / BreadcrumbsItemNavigation trail
PaginationPage navigation
NavLinkNavigation link with active state
Stepper / StepperStep / StepperCompletedStep progress
#![allow(unused)]
fn main() {
// Tabs
Tabs {
    TabsList {
        Tab { value: "gallery", "Gallery" }
        Tab { value: "messages", "Messages" }
        Tab { value: "settings", "Settings" }
    }
    TabsPanel { value: "gallery",
        Text { "Gallery content" }
    }
    TabsPanel { value: "messages",
        Text { "Messages content" }
    }
}

// Tab variants
Tabs { variant: "outline", /* ... */ }
Tabs { variant: "pills", /* ... */ }

// Accordion
Accordion {
    AccordionItem { value: "item-1",
        AccordionControl { "What is Rinch?" }
        AccordionPanel {
            Text { "A reactive GUI framework for Rust." }
        }
    }
    AccordionItem { value: "item-2",
        AccordionControl { "How do I get started?" }
        AccordionPanel {
            Text { "Add rinch to your Cargo.toml!" }
        }
    }
}

// Breadcrumbs
Breadcrumbs {
    BreadcrumbsItem { "Home" }
    BreadcrumbsItem { "Products" }
    BreadcrumbsItem { "Component" }
}
Breadcrumbs { separator: ">",
    BreadcrumbsItem { "Docs" }
    BreadcrumbsItem { "API" }
}

// Pagination
Pagination {}
Pagination { with_edges: true }

// NavLink
NavLink { label: "Dashboard", active: true }
NavLink { label: "Settings", description: "App configuration" }
NavLink { label: "Disabled", disabled: true }
NavLink { label: "With handler", onclick: move || navigate("/page") }

// Stepper
let step = Signal::new(1u32);
Stepper { active: step.get(),
    StepperStep { label: "Account", description: "Create account" }
    StepperStep { label: "Verify", description: "Verify email" }
    StepperStep { label: "Complete", description: "Get started" }
}
}

Overlays

ComponentDescription
ModalDialog overlay
DrawerSlide-out panel
NotificationToast notification
Popover / PopoverTarget / PopoverDropdownPositioned popup
DropdownMenu / DropdownMenuTarget / DropdownMenuDropdown / DropdownMenuItemDropdown menu
HoverCard / HoverCardTarget / HoverCardDropdownCard on hover
LoadingOverlayLoading overlay for containers

Modal, Drawer, and the top-anchored Notification positions automatically clear any window chrome rinch draws (the Linux in-app menu bar, the BorderlessWindow titlebar) by offsetting with --rinch-window-top-inset. The invisible click-catching backdrops behind DropdownMenu and Select deliberately do not — they must catch clicks on the chrome too.

If you hand-roll a position: fixed overlay, offset it the same way (top: var(--rinch-window-top-inset, 0px)); fixed resolves against the real viewport, so a bare top: 0 renders underneath the chrome. See Theming → Window Chrome Inset.

#![allow(unused)]
fn main() {
// Modal
let modal_open = Signal::new(false);

Button { onclick: move || modal_open.set(true), "Open Modal" }

Modal {
    opened: modal_open.get(),
    onclose: move || modal_open.set(false),
    title: "Confirm Action",
    size: "md",

    Text { "Are you sure you want to proceed?" }
    Space { h: "lg" }
    Group { justify: "end", gap: "sm",
        Button { variant: "outline", "Cancel" }
        Button { "Confirm" }
    }
}

// Drawer
Drawer {
    opened: drawer_open.get(),
    onclose: move || drawer_close.set(false),
    title: "Settings",
    position: "right",  // left, right, top, bottom
    size: "sm",

    // Drawer content
}

// Notification
Notification {
    opened: show_notification.get(),
    title: "Success!",
    color: "green",
    position: "top-right",
    onclose: move || close_notification.set(false),
    "Your changes have been saved."
}

// Popover
Popover { position: "bottom",
    PopoverTarget {
        Button { "Click me" }
    }
    PopoverDropdown {
        Text { "Popover content!" }
        Button { size: "xs", "Action" }
    }
}

// DropdownMenu
DropdownMenu {
    DropdownMenuTarget {
        Button { "Menu" }
    }
    DropdownMenuDropdown {
        DropdownMenuLabel { "Application" }
        DropdownMenuItem { "Settings" }
        DropdownMenuItem { "Profile" }
        DropdownMenuDivider {}
        DropdownMenuItem { color: "red", "Delete" }
    }
}

// HoverCard
HoverCard { position: "bottom",
    HoverCardTarget {
        Text { weight: "bold", "@username" }
    }
    HoverCardDropdown {
        Group { gap: "sm",
            Avatar { name: "User Name" }
            Stack { gap: "xs",
                Text { weight: "bold", "User Name" }
                Text { size: "sm", color: "dimmed", "Bio here" }
            }
        }
    }
}

// LoadingOverlay
div { style: "position: relative; height: 200px;",
    // Your content
    LoadingOverlay { visible: is_loading.get() }
}
}

Window Components

ComponentDescription
BorderlessWindowContainer for borderless/transparent windows with custom titlebar
use rinch::prelude::*;
use std::rc::Rc;

#[component]
fn app() -> NodeHandle {
    // For custom left section (e.g., menu button)
    let menu_open = Signal::new(false);
    let left_section: SectionRenderer = Rc::new(move |__scope| {
        rsx! {
            ActionIcon { onclick: move || menu_open.update(|v| *v = !*v),
                "☰"
            }
        }
    });

    rsx! {
        BorderlessWindow {
            title: "My App",
            radius: "md",  // none, xs, sm, md, lg, xl
            left_section: Some(left_section),
            on_minimize: || minimize_current_window(),
            on_maximize: || toggle_maximize_current_window(),
            on_close: || close_current_window(),

            // Your content here
            Stack { gap: "md",
                Title { order: 2, "Welcome!" }
                Text { "This is a borderless window." }
            }
        }
    }
}

fn main() {
    let window_props = WindowProps {
        title: "My App".into(),
        borderless: true,
        transparent: true,
        resize_inset: Some(8.0),
        ..Default::default()
    };

    run_with_window_props(app, window_props, None);
}

BorderlessWindow Props:

PropTypeDescription
titleOption<String>Window title in titlebar
radiusOption<String>Corner radius: none, xs, sm, md, lg, xl
show_minimizeboolShow minimize button (default: true)
show_maximizeboolShow maximize button (default: true)
show_closeboolShow close button (default: true)
left_sectionOption<SectionRenderer>Custom content for titlebar left
right_sectionOption<SectionRenderer>Custom content before controls
on_minimizeOption<Callback>Minimize callback
on_maximizeOption<Callback>Maximize callback
on_closeOption<Callback>Close callback

The component provides:

  • Rounded corners with configurable radius
  • Draggable titlebar (via app-region: drag)
  • Window control buttons with hover effects
  • Left/right custom sections for menu buttons or additional controls
  • Proper theming via CSS variables
  • --rinch-window-top-inset published for overlays (36px for the titlebar, plus the menu bar height when one sits below it)

The titlebar is an in-flow element, so your content sits below it automatically. A position: fixed overlay does not — it resolves against the real viewport and would cover the titlebar and its close button. Offset such overlays by var(--rinch-window-top-inset, 0px); see Theming → Window Chrome Inset.

Building Custom Components

See the dedicated Writing Components guide for the recommended approach using #[component] PascalCase functions. The manual Component trait approach below is only needed for advanced use cases.

Building Custom Component Crates (Advanced)

You can create your own component library crate that integrates with Rinch’s theme system.

1. Create the Crate

# Cargo.toml
[package]
name = "my-components"
version = "0.1.0"
edition = "2021"

[dependencies]
rinch-core = { path = "../rinch-core" }

2. Implement the Component Trait

Each component must implement the Component trait from rinch-core. Components receive a RenderScope for DOM construction and return a NodeHandle:

#![allow(unused)]
fn main() {
use rinch_core::{Component, NodeHandle, RenderScope};

/// A custom card component.
#[derive(Debug, Default)]
pub struct MyCard {
    /// Card title.
    pub title: Option<String>,
    /// Card variant.
    pub variant: Option<String>,
    /// Whether to show shadow.
    pub shadow: bool,
}

impl Component for MyCard {
    fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle {
        // Create the card element
        let card = scope.create_element("div");

        // Build CSS classes
        let mut classes = vec!["my-card".to_string()];

        if let Some(ref v) = self.variant {
            match v.as_str() {
                "elevated" => classes.push("my-card--elevated".to_string()),
                "outlined" => classes.push("my-card--outlined".to_string()),
                _ => {}
            }
        }

        if self.shadow {
            classes.push("my-card--shadow".to_string());
        }

        card.set_attribute("class", &classes.join(" "));

        // Add title if provided
        if let Some(ref title) = self.title {
            let title_div = scope.create_element("div");
            title_div.set_attribute("class", "my-card__title");
            title_div.set_text(title);
            card.append_child(&title_div);
        }

        // Append children
        for child in children {
            card.append_child(child);
        }

        card
    }
}
}

3. Add Event Handlers

For interactive components, add event listeners directly to DOM nodes:

#![allow(unused)]
fn main() {
use rinch_core::Callback;

pub struct MyButton {
    pub onclick: Option<Callback>,
}

impl Component for MyButton {
    fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle {
        let button = scope.create_element("button");
        button.set_attribute("class", "my-button");

        // Add click handler if provided
        if let Some(ref cb) = self.onclick {
            let cb = cb.clone();
            button.add_event_listener("click", move |_| cb.invoke());
        }

        // Append children
        for child in children {
            button.append_child(child);
        }

        button
    }
}
}

4. Create CSS Styles

Create a styles module that generates CSS for your components:

#![allow(unused)]
fn main() {
// src/styles/mod.rs
mod card;
mod button;

pub fn generate_all_styles() -> String {
    let mut css = String::new();
    css.push_str(&card::styles());
    css.push_str(&button::styles());
    css
}

// src/styles/card.rs
pub fn styles() -> String {
    r#"
.my-card {
    background: var(--rinch-color-body);
    border-radius: var(--rinch-radius-default);
    padding: var(--rinch-spacing-md);
}

.my-card--elevated {
    box-shadow: var(--rinch-shadow-md);
}

.my-card--outlined {
    border: 1px solid var(--rinch-color-border);
}

.my-card__title {
    font-size: var(--rinch-font-size-lg);
    font-weight: 600;
    margin-bottom: var(--rinch-spacing-sm);
}
"#.to_string()
}
}

5. Export Your Components

#![allow(unused)]
fn main() {
// src/lib.rs
pub mod styles;

mod card;
mod button;

pub use card::MyCard;
pub use button::MyButton;

// Re-export Component trait for convenience
pub use rinch_core::Component;

/// Generate all component CSS.
pub fn generate_css() -> String {
    styles::generate_all_styles()
}
}

6. Use Theme CSS Variables

Leverage Rinch’s theme variables for consistency:

VariableDescription
--rinch-primary-colorPrimary theme color
--rinch-color-bodyBackground color
--rinch-color-textText color
--rinch-color-dimmedDimmed/muted text
--rinch-color-borderBorder color
--rinch-color-{name}-{0-9}Color shades (e.g., --rinch-color-blue-5)
--rinch-spacing-{xs,sm,md,lg,xl}Spacing scale
--rinch-radius-{xs,sm,md,lg,xl}Border radius
--rinch-radius-defaultDefault radius
--rinch-shadow-{xs,sm,md,lg,xl}Box shadows
--rinch-font-size-{xs,sm,md,lg,xl}Font sizes
--rinch-font-familyDefault font
--rinch-font-family-monospaceMonospace font
--rinch-window-top-insetHeight of the window chrome rinch draws above your content. Not theme-generated — published at runtime by the in-app menu bar / BorderlessWindow titlebar. A position: fixed overlay should offset by it (top: var(--rinch-window-top-inset, 0px)), since fixed resolves against the real viewport and would otherwise slide underneath the chrome. See Theming

7. Use Your Component Crate

use rinch::prelude::*;
use my_components::{MyCard, MyButton};

#[component]
fn app() -> NodeHandle {
    rsx! {
        MyCard { title: "Hello", variant: "elevated", shadow: true,
            Text { "Card content here" }
            MyButton { onclick: move || println!("Clicked!"),
                "Click Me"
            }
        }
    }
}

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("blue".into()),
        ..Default::default()
    };
    run_with_theme("Custom Components", 800, 600, app, theme);
}

Component CSS Injection

Component CSS is automatically injected when you use ThemeProvider. If you need the CSS separately:

#![allow(unused)]
fn main() {
use rinch::components::generate_component_css;

let css = generate_component_css();
}

For custom component crates, inject your CSS alongside the theme:

#![allow(unused)]
fn main() {
use my_components::generate_css;

// Add to your HTML head or inject via ThemeProvider
let custom_css = generate_css();
}

Custom Components with #[component]

You can create reusable custom components using the #[component] macro. Components written in PascalCase become usable components in RSX:

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
pub fn MyCard(
    title: String,
    color: String,
    children: &[NodeHandle],
) -> NodeHandle {
    rsx! {
        div { class: "my-card",
            h2 { {title.clone()} }
            div { class: "card-body", style: {move || format!("color: {}", color.clone())},
                children
            }
        }
    }
}

// Use in RSX:
#[component]
fn app() -> NodeHandle {
    rsx! {
        MyCard { title: "Hello", color: "blue",
            Text { "Card content here" }
        }
    }
}
}

Key points:

  • PascalCase function names become components in RSX
  • children: &[NodeHandle] is a special parameter that receives child content
  • Use children in the body to render child elements
  • #[component] auto-injects __scope: &mut RenderScope as the first parameter
  • Props are regular Rust function parameters

Without children:

#![allow(unused)]
fn main() {
#[component]
pub fn StatusBadge(label: String, status: String) -> NodeHandle {
    rsx! {
        div { class: "badge",
            style: {move || format!("background: {}",
                if status == "success" { "green" } else { "red" })},
            {label.clone()}
        }
    }
}

// Use:
StatusBadge { label: "Active", status: "success" }
}

With callback props:

Callback and InputCallback have built-in defaults (no-op), so they work as direct props without Option wrapping:

#![allow(unused)]
fn main() {
#[component]
pub fn TodoItem(
    label: String,
    completed: bool,
    on_toggle: Callback,
    on_delete: Callback,
) -> NodeHandle {
    rsx! {
        Group { gap: "sm", align: "center",
            Checkbox {
                checked: completed,
                onchange: on_toggle,  // Forward directly — no Option unwrapping needed
            }
            Text { {label.clone()} }
            ActionIcon { variant: "subtle", color: "red",
                onclick: on_delete,
                "X"
            }
        }
    }
}

// Use — closures are auto-wrapped into Callback:
TodoItem {
    label: "Buy groceries",
    completed: false,
    on_toggle: move || toggle(id),
    on_delete: move || delete(id),
}
}

Prop type defaults:

The #[component] macro generates a Default impl for the component struct. Known types get sensible defaults automatically:

TypeDefault
StringString::new() (empty)
boolfalse
Option<T>None
Vec<T>Vec::new()
Numeric types0 / 0.0
CallbackNo-op (does nothing)
InputCallbackNo-op (does nothing)

Other types (e.g., custom structs) fall back to Default::default(), so they must implement Default.

Icon System

Rinch provides a type-safe icon system with a curated library of SVG icons. Instead of passing arbitrary HTML strings, components accept Icon enum values for discoverability and consistency.

Basic Usage

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::components::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        Alert { icon: Icon::InfoCircle, color: "blue", title: "Information",
            "This is an informational message."
        }

        Alert { icon: Icon::CheckCircle, color: "green", title: "Success",
            "Operation completed successfully."
        }

        Alert { icon: Icon::AlertTriangle, color: "yellow", title: "Warning",
            "Please review this carefully."
        }

        Alert { icon: Icon::XCircle, color: "red", title: "Error",
            "Something went wrong."
        }
    }
}
}

Available Icons

CategoryIcons
NavigationChevronUp, ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown, ArrowLeft, ArrowRight
ActionsClose, Check, Plus, Minus, Search, Settings, Edit, Trash
Status/AlertsInfoCircle, CheckCircle, AlertCircle, AlertTriangle, XCircle
ContentUser, Mail, Phone, Calendar, Clock, File, Folder, Image, Link, ExternalLink
UIEye, EyeOff, Menu, MoreHorizontal, MoreVertical, Loader, Quote

Components with Icon Support

ComponentIcon Props
Alerticon: Option<Icon>
Notificationicon: Option<Icon>
AccordionControlicon: Option<Icon>
Blockquoteicon: Option<Icon>
Listicon: Option<Icon>
ListItemicon: Option<Icon>
Steppercompleted_icon, progress_icon: Option<Icon>
StepperStepicon, completed_icon, progress_icon: Option<Icon>
NavLinkleft_section, right_section: Option<Icon>
DropdownMenuItemleft_section, right_section: Option<Icon>
Tableft_section, right_section: Option<Icon>

Examples

#![allow(unused)]
fn main() {
// NavLink with icons
NavLink {
    label: "Dashboard",
    left_section: Icon::Settings,
    active: true
}

// Tab with icon
Tabs {
    TabsList {
        Tab { value: "home", left_section: Icon::User, "Profile" }
        Tab { value: "settings", left_section: Icon::Settings, "Settings" }
    }
}

// Dropdown menu items with icons
DropdownMenu {
    DropdownMenuTarget {
        Button { "Actions" }
    }
    DropdownMenuDropdown {
        DropdownMenuItem { left_section: Icon::Edit, "Edit" }
        DropdownMenuItem { left_section: Icon::Trash, color: "red", "Delete" }
    }
}

// Blockquote with quote icon
Blockquote { icon: Icon::Quote, cite: "— Unknown",
    "The best code is no code at all."
}

// Stepper with custom icons
Stepper { active: 1, completed_icon: Icon::CheckCircle,
    StepperStep { label: "Account" }
    StepperStep { label: "Verify" }
    StepperStep { label: "Complete" }
}
}

Benefits of the Icon System

  1. Type Safety: Compiler catches typos and invalid icon names
  2. Discoverability: IDE autocomplete shows all available icons
  3. Consistency: All icons use the same SVG style and sizing
  4. Performance: Icons render as structured nodes, enabling differential updates instead of full re-renders

Reactive Component Props

Rinch supports two mechanisms for making component props reactive:

1. Reactive Closures on Any Prop (Re-renders Component)

Pass a closure {|| expr} to any component prop (like variant, color, size, disabled). When signals inside the closure change, the entire component re-renders with the new prop values:

#![allow(unused)]
fn main() {
let active = Signal::new(false);

rsx! {
    Button {
        variant: {|| if active.get() { "filled" } else { "light" }},
        color: {|| if active.get() { "green" } else { "gray" }},
        onclick: move || active.update(|v| *v = !*v),
        "Toggle me"
    }
}
}

This works on all component props — the macro detects closures and wraps the component in a reactive scope automatically.

2. _fn Props (Surgical DOM Updates)

For components that support _fn props, these provide more efficient updates that only touch the specific DOM node, without re-rendering the entire component. The rsx! macro auto-wraps _fn props — just pass a closure directly:

#![allow(unused)]
fn main() {
let is_checked = Signal::new(false);
let input_text = Signal::new(String::new());

rsx! {
    // Auto-wrapped: just pass a closure, no Rc::new() needed
    Checkbox {
        label: "Enable feature",
        checked_fn: move || is_checked.get(),
        onchange: move || is_checked.update(|v| *v = !*v),
    }

    TextInput {
        placeholder: "Type here...",
        value_fn: move || input_text.get(),
        oninput: move |value: String| input_text.set(value),
    }
}
}

Reactive Props Summary

| Prop Type | Accepts {|| ...} | Update Strategy | |—|—|—| | HTML element attributes | Yes | Surgical DOM update | | Component style:/class: | Yes | Surgical DOM update | | Component _fn props | Yes (auto-wrapped) | Surgical DOM update | | Component props (all others) | Yes | Full component re-render |

Components with _fn Props

ComponentReactive PropPurpose
Checkboxchecked_fnToggle checked class reactively
Switchchecked_fnToggle checked class reactively
Radiochecked_fnToggle checked class reactively
TextInputvalue_fnReactive value binding
NavLinkactive_fnToggle active class reactively
Progressvalue_fnUpdate progress bar width reactively
Modalopened_fnShow/hide modal reactively
Draweropened_fnShow/hide drawer reactively
Notificationopened_fnShow/hide notification reactively

Controlled Input Pattern

For controlled inputs, use value_fn + oninput together. value_fn keeps the DOM in sync with your signal; oninput updates the signal from user input. Without value_fn, programmatic signal.set("") won’t clear the input visually:

#![allow(unused)]
fn main() {
let text = Signal::new(String::new());

rsx! {
    TextInput {
        value_fn: move || text.get(),
        oninput: move |value: String| text.set(value),
        onsubmit: move || {
            println!("Submitted: {}", text.get());
            text.set(String::new());  // Clears the input thanks to value_fn
        },
    }
}
}

Customization

Components automatically respond to theme settings:

#![allow(unused)]
fn main() {
let theme = ThemeProviderProps {
    primary_color: Some("violet".into()),  // All primary-colored components use violet
    default_radius: Some("lg".into()),     // Larger border radius everywhere
    dark_mode: true,                       // Dark theme colors
    ..Default::default()
};

// Components automatically adapt to the theme
run_with_theme("My App", 800, 600, app, theme);
}

Component Props Reference

This page lists every prop for every component in rinch-components. All components use #[derive(Default)] unless noted, meaning Option<T> defaults to None and bool defaults to false.

String props: All text/string component props are now String type (not Option<String>). Empty string "" means “not set/use default”. The RSX macro auto-converts string literals: variant: "filled" becomes String::from("filled").

Float literals: Float literals are auto-wrapped: value: 30.0 becomes Some(30.0) for Option<f32> fields.

Universal props: All components support style: and class: in RSX, which are applied to the component’s root DOM element. These support reactive closures {|| expr}.

Style shorthands: All elements and components support CSS shorthand props like w, h, m, p, maw, etc. These expand to set_style() calls and compose with component styles. Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}):

#![allow(unused)]
fn main() {
// Shorthands work on both HTML elements and components
div { p: "md", m: "lg", w: "200px", "Styled div" }
Stack { gap: "md", p: "xl", maw: "600px",
    Text { "Content" }
}
}

See Style Shorthands for the full list.

Prop auto-wrapping: The rsx! macro automatically wraps prop values. See RSX Prop Transformation Rules — do NOT manually wrap in Some(...).

All props are reactive: Every component prop accepts a reactive closure {|| expr} in addition to a static value. When any prop uses a closure, the component automatically re-renders when the signals inside change:

#![allow(unused)]
fn main() {
let active = Signal::new(false);

// Static prop value
Button { variant: "filled", "Always filled" }

// Reactive prop value — re-renders when `active` changes
Button { variant: {|| if active.get() { "filled" } else { "outline" }}, "Toggle" }
}

For more efficient surgical updates (no full re-render), use _fn suffix props where available (e.g., value_fn, checked_fn, opened_fn).


Layout

Stack

Vertical flex container.

PropTypeDefaultDescription
gapOption<String>NoneSpacing between children (xs, sm, md, lg, xl or CSS value)
alignOption<String>NoneCSS align-items (e.g., “center”, “flex-start”)
justifyOption<String>NoneCSS justify-content

Group

Horizontal flex container.

PropTypeDefaultDescription
gapOption<String>NoneSpacing between children (xs, sm, md, lg, xl or CSS value)
alignOption<String>NoneCSS align-items
justifyOption<String>NoneCSS justify-content
wrapboolfalseEnable flex-wrap
growboolfalseChildren flex-grow: 1

SimpleGrid

Auto-layout grid.

PropTypeDefaultDescription
colsOption<u32>NoneNumber of columns (default 1)
min_child_widthOption<String>NoneMin column width for auto-fill; overrides cols
spacingOption<String>NoneGap between items (xs, sm, md, lg, xl or CSS value)
vertical_spacingOption<String>NoneVertical gap (xs, sm, md, lg, xl or CSS value); falls back to spacing

Container

Centered max-width wrapper.

PropTypeDefaultDescription
sizeOption<String>NoneMax-width (xs, sm, md, lg, xl)
fluidboolfalseFull width (no max-width)

Center

Centers content horizontally and vertically.

PropTypeDefaultDescription
inlineboolfalseUse inline-flex instead of flex

Space

Empty spacing element.

PropTypeDefaultDescription
wOption<String>NoneWidth (spacing scale or CSS value)
hOption<String>NoneHeight (spacing scale or CSS value)

Buttons

Button

PropTypeDefaultDescription
variantOption<String>None“filled”, “outline”, “light”, “subtle”, “transparent”, “white”, “default”, “gradient”
sizeOption<String>Nonexs, sm, md, lg, xl
colorOption<String>NoneTheme color name
disabledboolfalse
loadingboolfalse
full_widthboolfalse
radiusOption<String>NoneBorder radius override
onclickOption<Callback>NoneClick handler

ActionIcon

Icon-only button. For text-based action buttons, use Button with compact styling.

PropTypeDefaultDescription
iconOption<TablerIcon>NoneTabler icon to display
variantOption<String>NoneSame variants as Button
sizeOption<String>Nonexs, sm, md, lg, xl
colorOption<String>NoneTheme color name
radiusOption<String>None
disabledboolfalse
loadingboolfalse
onclickOption<Callback>NoneClick handler

CloseButton

PropTypeDefaultDescription
sizeOption<String>Nonexs, sm, md, lg, xl
radiusOption<String>None
disabledboolfalse
icon_sizeOption<u32>NoneCustom icon size in pixels
onclickOption<Callback>NoneClick handler

Form Inputs

TextInput

PropTypeDefaultDescription
labelOption<String>None
placeholderOption<String>None
descriptionOption<String>NoneHelp text below input
errorOption<String>NoneError message; shows error styling
sizeOption<String>Nonexs, sm, md, lg, xl
disabledboolfalse
requiredboolfalse
radiusOption<String>None
input_typeOption<String>NoneHTML input type (“text”, “email”, etc.)
valueOption<String>NoneStatic value
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
oninputOption<InputCallback>NoneReceives String
onsubmitOption<Callback>NoneFires on Enter key

Textarea

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
errorOption<String>None
placeholderOption<String>None
sizeOption<String>None
disabledboolfalse
requiredboolfalse
autosizeboolfalseAuto-resize textarea
min_rowsOption<u32>NoneVisible rows; sizes the control to that many lines. Defaults to 2 (HTML default) when unset. A larger CSS min-height wins
max_rowsOption<u32>NoneUpper bound on rows when autosize is set
valueOption<String>None
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
oninputOption<InputCallback>NoneReceives String

PasswordInput

Custom Default: toggle_visibility defaults to true.

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
errorOption<String>None
placeholderOption<String>None
valueOption<String>None
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
visibleboolfalsePassword visibility state
visible_fnOption<ReactiveBool>NoneReactive visibility (auto-wrapped)
disabledboolfalse
requiredboolfalse
autofocusboolfalse
sizeOption<String>None
radiusOption<String>None
toggle_visibilitybooltrueShow/hide the eye toggle button
ontoggleOption<Callback>NoneFires when visibility toggled
oninputOption<InputCallback>NoneReceives String

NumberInput

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
errorOption<String>None
placeholderOption<String>None
valueOption<f64>None
default_valueOption<f64>None
minOption<f64>None
maxOption<f64>None
stepOption<f64>None
decimal_scaleOption<u32>NoneNumber of decimal places
prefixOption<String>Nonee.g., “$”
suffixOption<String>Nonee.g., “kg”
disabledboolfalse
hide_controlsboolfalseHide +/- buttons
requiredboolfalse
sizeOption<String>None
radiusOption<String>None
onincrementOption<Callback>None
ondecrementOption<Callback>None
oninputOption<InputCallback>NoneReceives String from direct text entry

Checkbox

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
sizeOption<String>None
disabledboolfalse
checkedboolfalseStatic checked state
checked_fnOption<ReactiveBool>NoneReactive checked binding (auto-wrapped)
indeterminateboolfalse
onchangeOption<Callback>None

Switch

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
sizeOption<String>None
disabledboolfalse
checkedboolfalse
checked_fnOption<ReactiveBool>NoneReactive checked binding (auto-wrapped)
label_positionOption<String>None“left” or “right”
onchangeOption<Callback>None

Select

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
errorOption<String>None
placeholderOption<String>None
sizeOption<String>None
disabledboolfalse
requiredboolfalse
valueOption<String>None
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
onchangeOption<InputCallback>NoneReceives selected value as String

Options are passed as children: option { value: "us", "United States" }

Radio / RadioGroup

Radio:

PropTypeDefaultDescription
nameOption<String>NoneRadio group name
valueOption<String>NoneRadio value
labelOption<String>None
descriptionOption<String>None
checkedboolfalse
checked_fnOption<ReactiveBool>NoneReactive checked binding (auto-wrapped)
disabledboolfalse
sizeOption<String>None
colorOption<String>None
errorboolfalse
onchangeOption<Callback>None

RadioGroup:

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
errorOption<String>None
sizeOption<String>None
orientationOption<String>None“horizontal” or “vertical”

Slider

PropTypeDefaultDescription
minOption<f64>None
maxOption<f64>None
valueOption<f64>NoneStatic value
value_signalOption<Signal<f64>>NoneDirect signal binding
stepOption<f64>None
sizeOption<String>None
colorOption<String>None
radiusOption<String>None
disabledboolfalse
labelOption<String>NoneTooltip label format
show_label_on_hoverboolfalse
label_always_onboolfalse
onchangeOption<ValueCallback<f64>>NoneReceives f64

Color

ColorSwatch

A colored square with checkerboard transparency indication.

PropTypeDefaultDescription
colorString""CSS color value
sizeString"28px"Width and height
radiusString"sm"Border radius (xs, sm, md, lg, xl or CSS value)
with_shadowboolfalseShow box shadow
onclickOption<Callback>NoneClick handler

ColorPicker

Interactive color picker with saturation panel, hue/alpha sliders, hex input, and swatches.

PropTypeDefaultDescription
formatString"hex"Output format: hex, hexa, rgb, rgba, hsl, hsla
valueString""Initial color (any parseable CSS color)
value_fnOption<ReactiveString>NoneReactive external value binding
onchangeOption<InputCallback>NoneFires formatted color string on change
alphaboolfalseShow alpha slider
swatchesVec<String>[]Preset swatch colors
swatches_per_rowOption<usize>7Swatches per row
sizeString"md"Size: sm, md, lg, xl
with_inputboolfalseShow hex text input

ColorInput

Text input with inline color preview and dropdown ColorPicker.

PropTypeDefaultDescription
labelString""Input label
descriptionString""Description text below the input
errorString""Error message (shows error styling)
placeholderString""Placeholder text
sizeString""Input size
radiusString""Border radius
disabledboolfalseDisable the input
valueString""Current color value
value_fnOption<ReactiveString>NoneReactive value binding
onchangeOption<InputCallback>NoneFires formatted color string on change
formatString"hex"Output format
alphaboolfalseShow alpha slider in picker
swatchesVec<String>[]Preset swatch colors
swatches_per_rowOption<usize>7Swatches per row
close_on_click_outsideboolfalseClose dropdown on outside click
disallow_inputboolfalseDisallow typing (picker only)

Typography

Text

PropTypeDefaultDescription
sizeOption<String>Nonexs, sm, md, lg, xl
weightOption<String>NoneCSS font-weight
colorOption<String>NoneTheme color or “dimmed”
alignOption<String>NoneCSS text-align
inlineboolfalseUse <span> instead of <p>

Title

PropTypeDefaultDescription
orderOption<u8>NoneHeading level 1-6
alignOption<String>NoneCSS text-align
sizeOption<String>NoneOverride size independent of order

Code

PropTypeDefaultDescription
blockboolfalseBlock display (<pre>) vs inline (<code>)
colorOption<String>None

Kbd

PropTypeDefaultDescription
sizeOption<String>Nonexs, sm, md, lg, xl

Anchor

PropTypeDefaultDescription
hrefOption<String>None
targetOption<String>Nonee.g., “_blank”
sizeOption<String>None
underlineboolfalse

Blockquote

PropTypeDefaultDescription
citeOption<String>NoneCitation source
iconOption<Icon>None
colorOption<String>None
radiusOption<String>None

Mark

PropTypeDefaultDescription
colorOption<String>NoneHighlight background color

Highlight

Custom Default: ignore_case defaults to true.

PropTypeDefaultDescription
textOption<String>NoneFull text to display
highlightOption<String>NoneSubstring(s) to highlight
colorOption<String>NoneHighlight color
ignore_casebooltrueCase-insensitive matching

Data Display

Avatar

PropTypeDefaultDescription
srcOption<String>NoneImage URL
altOption<String>None
nameOption<String>NoneFor initials fallback
sizeOption<String>None
radiusOption<String>None
colorOption<String>None
variantOption<String>None“filled”, “light”, “outline”

AvatarGroup: spacing: Option<String> — overlap spacing.

Badge

PropTypeDefaultDescription
variantOption<String>None“filled”, “light”, “outline”, “dot”, “transparent”, “white”, “default”, “gradient”
sizeOption<String>None
colorOption<String>None
radiusOption<String>None
full_widthboolfalse

Card

PropTypeDefaultDescription
shadowOption<String>Nonexs, sm, md, lg, xl
paddingOption<String>None
radiusOption<String>None
with_borderboolfalse

CardSection: inherit_padding: bool, with_border: bool.

Paper

PropTypeDefaultDescription
shadowOption<String>Nonexs, sm, md, lg, xl
pOption<String>NonePadding (spacing scale)
radiusOption<String>None
with_borderboolfalse

Divider

PropTypeDefaultDescription
orientationOption<String>None“horizontal” or “vertical”
sizeOption<String>None
labelOption<String>NoneText label in the divider
label_positionOption<String>None“left”, “center”, “right”

Fieldset

PropTypeDefaultDescription
legendOption<String>None
variantOption<String>None“default”, “filled”, “unstyled”
sizeOption<String>None
disabledboolfalse

Image

PropTypeDefaultDescription
srcOption<String>NoneImage URL
altOption<String>None
widthOption<String>NoneCSS width
heightOption<String>NoneCSS height
fitOption<String>NoneCSS object-fit
radiusOption<String>None
fallback_srcOption<String>NoneFallback image URL
captionOption<String>NoneCaption text below image

List / ListItem

List:

PropTypeDefaultDescription
typeOption<String>None“ordered” or “unordered”
sizeOption<String>None
spacingOption<String>None
centerboolfalseCenter items with icons
iconOption<Icon>NoneDefault icon for all items
with_paddingboolfalse

ListItem: icon: Option<Icon> — per-item icon override.


Feedback

Alert

PropTypeDefaultDescription
colorOption<String>None
variantOption<String>None“filled”, “light”, “outline”, “transparent”, “white”, “default”
titleOption<String>None
radiusOption<String>None
with_close_buttonboolfalse
iconOption<Icon>None
oncloseOption<Callback>None

Loader

PropTypeDefaultDescription
typeOption<String>None“oval”, “bars”, “dots”
sizeOption<String>None
colorOption<String>None

Progress

PropTypeDefaultDescription
valueOption<f32>NonePercentage 0-100
value_fnOption<ReactiveF32>NoneReactive value binding (auto-wrapped)
colorOption<String>None
sizeOption<String>None
radiusOption<String>None
stripedboolfalse
animatedboolfalse

Skeleton

Custom Default: animate and visible default to true.

PropTypeDefaultDescription
widthOption<String>None
heightOption<String>None
radiusOption<String>None
circleboolfalse
animatebooltrue
visiblebooltrue

Overlays

Tooltip

PropTypeDefaultDescription
labelOption<String>NoneTooltip text
positionOption<String>None“top”, “bottom”, “left”, “right”
colorOption<String>None
openedboolfalse
disabledboolfalse
with_arrowboolfalse
multilineboolfalse
widthOption<String>None

Custom Default: with_overlay, close_on_click_outside, close_on_escape, with_close_button, lock_scroll, trap_focus all default to true.

Positioned with top: var(--rinch-window-top-inset, 0px), so it clears any window chrome rinch draws (the Linux in-app menu bar, the BorderlessWindow titlebar) and is flush with the top of a plain window. See Theming.

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
titleOption<String>None
sizeOption<String>None
radiusOption<String>None
with_overlaybooltrue
overlay_opacityOption<f32>None
overlay_blurOption<String>None
centeredboolfalse
close_on_click_outsidebooltrue
close_on_escapebooltrue
with_close_buttonbooltrue
paddingOption<String>None
z_indexOption<i32>None
lock_scrollbooltrue
trap_focusbooltrue
oncloseOption<Callback>None

Drawer

Custom Default: with_overlay, close_on_click_outside, close_on_escape, with_close_button, lock_scroll, trap_focus all default to true.

Positioned with top: var(--rinch-window-top-inset, 0px), so it clears any window chrome rinch draws (the Linux in-app menu bar, the BorderlessWindow titlebar) and is flush with the top of a plain window. See Theming.

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
titleOption<String>None
positionOption<String>None“left”, “right”, “top”, “bottom”
sizeOption<String>None
with_overlaybooltrue
overlay_opacityOption<f32>None
close_on_click_outsidebooltrue
close_on_escapebooltrue
with_close_buttonbooltrue
paddingOption<String>None
z_indexOption<i32>None
lock_scrollbooltrue
trap_focusbooltrue
oncloseOption<Callback>None

Notification

Custom Default: with_close_button defaults to true.

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
titleOption<String>None
colorOption<String>None
positionOption<String>NoneToast position. The top-* variants offset by --rinch-window-top-inset so they clear window chrome
radiusOption<String>None
with_close_buttonbooltrue
with_borderboolfalse
iconOption<Icon>None
auto_closeu320Auto-close delay in ms (0 = disabled)
loadingboolfalse
z_indexOption<i32>None
oncloseOption<Callback>None

Popover

Custom Default: close_on_click_outside and close_on_escape default to true.

PropTypeDefaultDescription
openedboolfalse
positionOption<String>None
offsetOption<i32>None
radiusOption<String>None
shadowOption<String>None
with_arrowboolfalse
arrow_sizeOption<f32>None
arrow_offsetOption<f32>None
close_on_click_outsidebooltrue
close_on_escapebooltrue
widthOption<String>None
z_indexOption<i32>None
trap_focusboolfalse

Sub-components: PopoverTarget (no props), PopoverDropdown (no props).

ContextMenu

A right-click context menu. No props on the wrapper — state is managed internally.

Sub-components: ContextMenuTarget (no props), ContextMenuDropdown (no props).

Use DropdownMenuItem and DropdownMenuDivider as children of ContextMenuDropdown.

Custom Default: close_on_click_outside and close_on_item_click default to true.

PropTypeDefaultDescription
openedboolfalse
positionOption<String>None
offsetOption<i32>None
radiusOption<String>None
shadowOption<String>None
close_on_click_outsidebooltrue
close_on_item_clickbooltrue
widthOption<String>None
z_indexOption<i32>None

DropdownMenuTarget, DropdownMenuDropdown, DropdownMenuLabel, DropdownMenuDivider: No props.

DropdownMenuItem:

PropTypeDefaultDescription
left_sectionOption<Icon>None
right_sectionOption<Icon>None
colorOption<String>None
disabledboolfalse
onclickOption<Callback>None

HoverCard

PropTypeDefaultDescription
positionOption<String>None
offsetOption<i32>None
radiusOption<String>None
shadowOption<String>None
widthOption<String>None
open_delayOption<u32>None
close_delayOption<u32>None
with_arrowboolfalse

Sub-components: HoverCardTarget (no props), HoverCardDropdown (no props).

LoadingOverlay

PropTypeDefaultDescription
visibleboolfalse
overlay_opacityOption<f32>None
overlay_blurOption<String>None
loader_typeOption<String>None
loader_sizeOption<String>None
loader_colorOption<String>None
radiusOption<String>None
z_indexOption<i32>None
transition_durationOption<u32>None

Tabs

PropTypeDefaultDescription
valueOption<String>NoneActive tab value
default_valueOption<String>None
variantOption<String>None“default”, “outline”, “pills”
orientationOption<String>None“horizontal”, “vertical”
positionOption<String>None
growboolfalse
colorOption<String>None
radiusOption<String>None

TabsList: grow: bool, justify: Option<String>.

Tab:

PropTypeDefaultDescription
valueOption<String>NoneTab identifier
disabledboolfalse
left_sectionOption<Icon>None
right_sectionOption<Icon>None
onclickOption<Callback>None

TabsPanel: value: Option<String> — matches the Tab value.

Accordion

PropTypeDefaultDescription
valueOption<String>NoneActive item value
default_valueOption<String>None
variantOption<String>None“default”, “contained”, “filled”, “separated”
radiusOption<String>None
multipleboolfalseAllow multiple open items
chevron_positionOption<String>None“left”, “right”
disable_chevron_rotationboolfalse

AccordionItem: value: Option<String>.

AccordionControl: disabled: bool, icon: Option<Icon>, onclick: Option<Callback>.

AccordionPanel: No props.

PropTypeDefaultDescription
separatorOption<String>NoneCustom separator character
separator_marginOption<String>NoneSpacing around separator

BreadcrumbsItem: href: Option<String>.

Pagination

Custom Default: total, value, siblings, boundaries default to 1; with_controls defaults to true.

PropTypeDefaultDescription
totalu321Total number of pages
valueu321Current page
siblingsu321Pages visible on each side
boundariesu321Pages at start/end
sizeOption<String>None
radiusOption<String>None
with_edgesboolfalseShow first/last page buttons
with_controlsbooltrueShow prev/next buttons
colorOption<String>None
disabledboolfalse
gapOption<String>None
onchangeOption<ValueCallback<u32>>NoneReceives page number
PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
hrefOption<String>None
activeboolfalse
active_fnOption<ReactiveBool>NoneReactive active binding (auto-wrapped)
variantOption<String>None“light”, “filled”, “subtle”
colorOption<String>None
left_sectionOption<Icon>None
right_sectionOption<Icon>None
disabledboolfalse
children_offsetOption<String>NoneIndentation for nested NavLinks
openedboolfalseNested section expanded
default_openedboolfalse
no_wrapboolfalse
onclickOption<Callback>None

Stepper

PropTypeDefaultDescription
activeu320Active step index
sizeOption<String>None
orientationOption<String>None“horizontal”, “vertical”
colorOption<String>None
radiusOption<String>None
icon_sizeOption<String>None
allow_next_steps_selectboolfalse
completed_iconOption<Icon>NoneDefault completed icon for all steps
progress_iconOption<Icon>NoneDefault in-progress icon

StepperStep:

PropTypeDefaultDescription
labelOption<String>None
descriptionOption<String>None
iconOption<Icon>NoneDefault icon
completed_iconOption<Icon>NonePer-step override
progress_iconOption<Icon>NonePer-step override
allow_step_clickboolfalse
allow_step_selectboolfalse
loadingboolfalse
stateOption<String>None“step-progress”, “step-completed”, “step-inactive”
stepOption<u32>NoneStep index

StepperCompleted: No props.

Tree

Custom Default: level_offset defaults to Some("md"), expand_on_click defaults to true.

PropTypeDefaultDescription
dataVec<TreeNodeData>[]Tree data
treeOption<UseTreeReturn>NoneState from UseTreeReturn::new()
level_offsetOption<String>Some("md")Indentation per level
expand_on_clickbooltrueClick expands/collapses
select_on_clickboolfalseClick selects
render_nodeOption<RenderTreeNode>NoneCustom node renderer
onselectOption<ValueCallback<String>>None
onexpandOption<ValueCallback<String>>None
oncollapseOption<ValueCallback<String>>None

TreeNodeData: value: String, label: String, children: Vec<TreeNodeData>, disabled: bool, icon: Option<Icon>, payload: Option<Rc<dyn Any>>.


Window

BorderlessWindow

Custom Default: show_minimize, show_maximize, show_close all default to true.

PropTypeDefaultDescription
titleOption<String>NoneWindow title in titlebar
radiusOption<String>NoneCorner radius (none, xs, sm, md, lg, xl)
show_minimizebooltrue
show_maximizebooltrue
show_closebooltrue
left_sectionOption<SectionRenderer>NoneCustom titlebar left content
right_sectionOption<SectionRenderer>NoneCustom content before controls
on_minimizeOption<Callback>None
on_maximizeOption<Callback>None
on_closeOption<Callback>None

Callback Types Reference

TypeSignatureUsed By
CallbackRc<dyn Fn()>onclick, onclose, onsubmit, onchange (toggle)
InputCallbackRc<dyn Fn(String)>oninput, onchange (value)
ValueCallback<T>Rc<dyn Fn(T)>Slider (f64), Pagination (u32), Tree (String)
ReactiveBoolRc<dyn Fn() -> bool>checked_fn, active_fn, opened_fn, visible_fn
ReactiveStringRc<dyn Fn() -> String>value_fn
ReactiveF32Rc<dyn Fn() -> f32>Progress value_fn
SectionRendererRc<dyn Fn(&mut RenderScope) -> NodeHandle>BorderlessWindow sections

All callback/reactive props are auto-wrapped by the rsx! macro — just pass closures directly, do not wrap in Some(...) or Rc::new(...).

Theming

Rinch’s theme system is CSS-variable-based, Mantine-inspired, and designed to be extended rather than fought against. Enable it with the theme feature (auto-enabled by components):

rinch = { git = "...", features = ["desktop", "theme"] }

Quick Start

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("cyan".into()),
        default_radius: Some("md".into()),
        dark_mode: false,
        ..Default::default()
    };
    run_with_theme("My App", 800, 600, app, theme);
}

That’s it. Every component picks up your colors, radius, and spacing through CSS variables. Toggle dark_mode: true and the whole UI flips. All semantic colors — body background, text, borders, placeholders — adjust automatically.

Use run() instead of run_with_theme() to get the default theme (blue primary, light mode, small radius).

ThemeProviderProps

PropTypeDefaultDescription
primary_colorOption<String>"blue"Primary color name
primary_shadeOption<u8>6Shade index (0-9)
default_radiusOption<String>"sm"Border radius (xs, sm, md, lg, xl)
font_familyOption<String>System fontsPrimary font stack
font_family_monospaceOption<String>System monoMonospace font stack
dark_modeboolfalseDark mode

CSS Variables

The theme generates CSS custom properties you can use anywhere in your styles.

One variable in this list — --rinch-window-top-inset — is not generated by the theme. It is published at runtime by whatever window chrome rinch is drawing, and is documented here because you consume it the same way as the rest.

Colors

14 named palettes, each with 10 shades (0 = lightest, 9 = darkest):

var(--rinch-color-blue-0)     /* Lightest blue */
var(--rinch-color-blue-5)     /* Mid blue */
var(--rinch-color-blue-9)     /* Darkest blue */

var(--rinch-primary-color)    /* Primary at your chosen shade */
var(--rinch-primary-color-0)  /* Lightest primary */
var(--rinch-primary-color-9)  /* Darkest primary */

Available palettes: dark, gray, red, pink, grape, violet, indigo, blue, cyan, teal, green, lime, yellow, orange.

Semantic Colors

These flip automatically in dark mode:

var(--rinch-color-body)        /* Background (#f8f9fa light, dark gray dark) */
var(--rinch-color-text)        /* Primary text */
var(--rinch-color-dimmed)      /* Secondary text */
var(--rinch-color-border)      /* Borders */
var(--rinch-color-placeholder) /* Input placeholders */

Spacing

var(--rinch-spacing-xs)   /* 10px */
var(--rinch-spacing-sm)   /* 12px */
var(--rinch-spacing-md)   /* 16px */
var(--rinch-spacing-lg)   /* 20px */
var(--rinch-spacing-xl)   /* 32px */

Border Radius

var(--rinch-radius-xs)       /* 2px */
var(--rinch-radius-sm)       /* 4px */
var(--rinch-radius-md)       /* 8px */
var(--rinch-radius-lg)       /* 16px */
var(--rinch-radius-xl)       /* 32px */
var(--rinch-radius-default)  /* Whatever you set in ThemeProviderProps */

Typography

var(--rinch-font-size-xs)   /* 12px */
var(--rinch-font-size-sm)   /* 14px */
var(--rinch-font-size-md)   /* 16px */
var(--rinch-font-size-lg)   /* 18px */
var(--rinch-font-size-xl)   /* 20px */

var(--rinch-line-height-xs) /* 1.4 */
var(--rinch-line-height-md) /* 1.55 */

var(--rinch-font-family)
var(--rinch-font-family-monospace)

/* Heading sizes (h1-h6) */
var(--rinch-h1-font-size)
var(--rinch-h1-line-height)
var(--rinch-h1-font-weight)

Shadows

var(--rinch-shadow-xs)
var(--rinch-shadow-sm)
var(--rinch-shadow-md)
var(--rinch-shadow-lg)
var(--rinch-shadow-xl)

Window Chrome Inset

var(--rinch-window-top-inset)  /* Height of the chrome rinch draws above your content */

Rinch sometimes draws window chrome itself: the in-app menu bar on Linux (28px) and the BorderlessWindow titlebar (36px). That chrome reserves space with in-document padding, so normal flow content clears it automatically.

A position: fixed element does not. Fixed elements resolve against the real viewport — that is what browsers do, and what rinch-web does on the real DOM — so a top: 0 overlay slides underneath the menu bar or titlebar.

The chrome therefore publishes its own height as --rinch-window-top-inset. It’s an ordinary inherited custom property, so any fixed descendant can opt in:

#![allow(unused)]
fn main() {
rsx! {
    div { style: "
        position: fixed;
        top: var(--rinch-window-top-inset, 0px);
        left: 0; right: 0; bottom: 0;
    ",
        "Full-height overlay that clears the menu bar"
    }
}
}

The 0px fallback matters: on a plain window there is no chrome and the variable is never set, so the overlay stays flush with the top.

Drawer, Modal, and the top-anchored Notification positions already do this. Deliberate full-window layers — the backdrops behind DropdownMenu and Select, which must catch clicks on the chrome too — do not, and shouldn’t.

Using Theme Variables in Your Styles

#![allow(unused)]
fn main() {
rsx! {
    div { style: "
        background: var(--rinch-color-body);
        padding: var(--rinch-spacing-md);
        border-radius: var(--rinch-radius-default);
        box-shadow: var(--rinch-shadow-sm);
    ",
        h1 { style: "color: var(--rinch-primary-color);", "Themed!" }
        p { style: "color: var(--rinch-color-dimmed);", "Using theme variables." }
    }
}
}

Or use CSS shorthand props, which resolve scale values automatically:

#![allow(unused)]
fn main() {
rsx! {
    div { p: "md", m: "sm",  // -> var(--rinch-spacing-md), var(--rinch-spacing-sm)
        "Shorthand is nicer"
    }
}
}

Extending the Theme

Scoped Overrides

CSS variables cascade. Override them on a container div and everything inside picks up the change:

#![allow(unused)]
fn main() {
rsx! {
    div { style: "
        --rinch-primary-color: #ff6b6b;
        --rinch-radius-default: 0px;
    ",
        // Red primary, sharp corners — only inside this div
        Button { "I'm red and sharp" }
        TextInput { placeholder: "Me too" }
    }
}
}

This is how you create themed sections, cards with different accent colors, or “danger zone” areas with red buttons — without changing the global theme.

Replacing the Theme Entirely

The theme is just CSS. If you hate it, replace it:

#![allow(unused)]
fn main() {
use rinch::theme::{Theme, ColorName};

let theme = Theme::builder()
    .primary_color(ColorName::Cyan)
    .dark_mode(true)
    .build();

// Generate the CSS string and do whatever you want with it
let css = rinch_theme::generate_theme_css(&theme);
}

Or ignore the theme system entirely and write your own CSS variables, your own classes, your own inline styles. Rinch components read from --rinch-* variables. If those variables exist, components use them. If they don’t, components fall back to hardcoded defaults. You’re never locked in.

Dark Mode

#![allow(unused)]
fn main() {
let theme = ThemeProviderProps {
    dark_mode: true,
    ..Default::default()
};
}

In dark mode, semantic colors flip:

  • --rinch-color-body becomes dark gray
  • --rinch-color-text becomes light
  • Component backgrounds, borders, and hover states all adjust

For dynamic dark mode toggling (e.g., a switch in your app), use dark_mode_fn on WASM or re-create the theme and call update_theme() on desktop.

Color Palette Reference

ColorShade 0 (lightest)Shade 6 (primary)Notes
dark#C1C2C5#1A1B1EDark grays
gray#f8f9fa#868e96gray-0 = default body background
red#fff5f5#fa5252
pink#fff0f6#e64980
grape#f8f0fc#be4bdb
violet#f3f0ff#7950f2
indigo#edf2ff#4c6ef5
blue#e7f5ff#228be6Default primary
cyan#e3fafc#15aabf
teal#e6fcf5#12b886
green#ebfbee#40c057
lime#f4fce3#82c91e
yellow#fff9db#fab005
orange#fff4e6#fd7e14

Gotcha: --rinch-color-gray-0 is #f8f9fa, which matches the default body background. If you use it as a card background, it’ll be invisible. Use gray-1 or higher for visible backgrounds.

Running on WASM

Rinch compiles to WebAssembly with a browser-native DOM backend. Instead of painting pixels to a canvas, the WASM build creates real <div>, <span>, and <button> elements. The browser handles layout, CSS, text rendering, and painting natively.

The result: ~3MB binary, no JavaScript framework, real DOM elements you can inspect in Chrome DevTools.

How It Works

The magic is in the DomDocument trait. On desktop, NodeHandle talks to RinchDocument (Stylo + Taffy + Parley + Vello/tiny-skia). On WASM, it talks to WebDocument (web_sys). Your components, signals, effects, and stores don’t know or care which one they’re using.

Desktop: Signal -> Effect -> NodeHandle -> RinchDocument -> tiny-skia pixels
WASM:    Signal -> Effect -> NodeHandle -> WebDocument   -> real browser DOM

Same rsx!. Same Signal::new(). Same Button { "Click me" }. Different backend.

Project Setup

The WASM entry point is its own crate (separate from the desktop workspace) so it can carry wasm-tuned build profiles and a Trunk setup. It depends on rinch-web — the browser-native DOM backend that implements DomDocument over web_sys. Structure your project like this:

my-app/              # Shared library: components, stores, logic
my-app-desktop/      # Desktop entry point
my-app-web/          # WASM entry point (separate workspace)

The Shared Library

# my-app/Cargo.toml
[package]
name = "my-app"

[dependencies]
rinch = { git = "...", default-features = false, features = ["components", "theme"] }
#![allow(unused)]
fn main() {
// my-app/src/lib.rs
use rinch::prelude::*;

#[component]
pub fn app() -> NodeHandle {
    let count = Signal::new(0);
    rsx! {
        Stack { gap: "md", p: "xl",
            Title { order: 1, "My App" }
            Button { onclick: move || count.update(|n| *n += 1),
                {|| format!("Clicked {} times", count.get())}
            }
        }
    }
}
}

The Desktop Entry Point

# my-app-desktop/Cargo.toml
[dependencies]
rinch = { git = "...", features = ["desktop", "components", "theme"] }
my-app = { path = "../my-app" }
// my-app-desktop/src/main.rs
use rinch::prelude::*;

fn main() {
    run("My App", 800, 600, my_app::app);
}

The WASM Entry Point

# my-app-web/Cargo.toml
[package]
name = "my-app-web"

[dependencies]
rinch = { git = "...", default-features = false, features = ["components", "theme"] }
rinch-core = { git = "..." }
rinch-web = { git = "..." }   # browser-native DOM backend: WebDocument + mount
my-app = { path = "../my-app" }
wasm-bindgen = "0.2"
console_error_panic_hook = "0.1"
// my-app-web/src/main.rs
use wasm_bindgen::prelude::*;
use rinch_core::element::ThemeProviderProps;

#[wasm_bindgen(start)]
pub fn start() {
    console_error_panic_hook::set_once();

    // `rinch_web::mount` creates the WebDocument, builds your component tree,
    // wires browser event delegation, and installs the theme CSS.
    let theme = ThemeProviderProps {
        dark_mode: false,
        ..Default::default()
    };
    rinch_web::mount(theme, my_app::app);
}

fn main() {}

Islands (multiple roots per page)

mount boots a single whole-page app. For a static-first page (e.g. server-rendered HTML) you can instead hydrate independent rinch widgets into specific placeholder elements — the “islands” pattern. Each island is its own reactive root; they coexist on one page and can be unmounted individually.

<article>… static server-rendered content …</article>
<div id="comments"></div>
<div id="search"></div>
#![allow(unused)]
fn main() {
#[wasm_bindgen(start)]
pub fn start() {
    let theme = ThemeProviderProps::default();

    // Mount into an element selected by CSS selector …
    rinch_web::mount_selector("#comments", theme.clone(), comments_app);

    // … or into a `web_sys::Element` you already hold:
    let el = web_sys::window().unwrap().document().unwrap()
        .get_element_by_id("search").unwrap();
    let handle = rinch_web::mount_into(&el, theme, search_app);

    // Later, tear that root down (removes its DOM + handlers; the host stays):
    // handle.unmount();
}
}
APIPurpose
mount_into(&Element, theme, build) -> RootHandleMount a root into an existing element
mount_selector("#id", theme, build) -> Option<RootHandle>Same, by CSS selector (None if no match)
RootHandle::unmount(self)Remove that root’s DOM subtree and its event handlers

Islands adopt their host element directly (no #rinch-root/#rinch-body wrappers), share one page-global theme <style>, and install document event listeners once — so any number can coexist without interfering. Dropping a RootHandle keeps the root mounted; only unmount() tears it down. See examples/islands-web for a runnable demo.

Building

Trunk handles WASM compilation, asset bundling, and dev server:

cargo install trunk

cd my-app-web
trunk serve --release --port 8080

Add an index.html:

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body></body>
</html>

Trunk does the rest.

With wasm-pack

cd my-app-web
wasm-pack build --target web --release

Manual

cd my-app-web
cargo build --target wasm32-unknown-unknown --release
wasm-bindgen target/wasm32-unknown-unknown/release/my_app_web.wasm --out-dir pkg --target web

What Works

Everything that goes through NodeHandle works:

  • Signals, Effects, Memos, derived state
  • Stores and Context
  • All 60+ components
  • The theme system and CSS variables
  • Event handling (onclick, oninput, etc.)
  • Reactive control flow (if, for, match)
  • CSS shorthand props

The abstraction is clean. If your component code doesn’t import anything from rinch-dom or winit directly, it’ll work on WASM without changes.

What Doesn’t (Yet)

  • Custom painting — Vello and tiny-skia don’t run in the browser. The browser paints for you instead.
  • Game engine embeddingRenderSurface and RinchContext are desktop-only.
  • Native menus — Browsers have their own menu system. Use Rinch components instead.
  • File dialogs — Use the browser’s <input type="file"> or the File System Access API.
  • System tray — Not a thing in browsers.

Theme in WASM

The theme system works by injecting a <style> tag into <head>. Dynamic theme changes (primary color, dark mode) work via reactive props:

#![allow(unused)]
fn main() {
let dark = Signal::new(false);

// ThemeProviderProps with reactive dark_mode
let theme = ThemeProviderProps {
    dark_mode_fn: Some(Rc::new(move || dark.get())),
    ..Default::default()
};
}

Binary Size

A full UI Zoo demo (12 sections, 60+ components) compiles to ~3.3MB after wasm-opt. A minimal app is well under 500KB. The Rust compiler’s dead code elimination is doing real work here — unused components, icons, and CSS don’t make it into the binary.

Reference

See examples/ui-zoo-web in the repo for a complete, working WASM app with sidebar navigation, theme switching, and all components.

Windows

Rinch supports multi-window applications with window configuration at the runtime level. Windows are created using WindowProps when launching the application.

Basic Window

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div {
            h1 { "Window Content" }
        }
    }
}

fn main() {
    // Window title, width, height, and app function
    run("My Application", 800, 600, app);
}

Window Properties

Configure windows using WindowProps:

use rinch::prelude::*;
use rinch_core::element::WindowProps;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div { "Content" }
    }
}

fn main() {
    let props = WindowProps {
        title: "My Application".into(),
        width: 800,
        height: 600,
        x: Some(100),           // Initial x position
        y: Some(100),           // Initial y position
        borderless: false,      // Show window decorations
        resizable: true,        // Allow resizing
        transparent: false,     // No transparency
        always_on_top: false,   // Normal z-order
        visible: true,          // Show on creation
        resize_inset: None,     // No custom resize handles
    };

    run_with_window_props(app, props, None);
}
PropertyTypeDefaultDescription
titleString“Rinch Window”Window title bar text
widthu32800Initial window width in pixels
heightu32600Initial window height in pixels
xOption<i32>NoneInitial x position (centered if None)
yOption<i32>NoneInitial y position (centered if None)
borderlessboolfalseRemove native window decorations
resizablebooltrueAllow window resizing
transparentboolfalseEnable window transparency
always_on_topboolfalseKeep window above others
visiblebooltrueInitial visibility state
resize_insetOption<f32>NoneResize handle inset for borderless windows

Frameless Windows (Custom Chrome)

Create frameless windows for custom title bars and window chrome using borderless: true:

use rinch::prelude::*;
use rinch_core::element::WindowProps;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div { class: "custom-titlebar",
            "My Custom Title Bar"
        }
        div { class: "content",
            "Window content"
        }
    }
}

fn main() {
    let props = WindowProps {
        title: "Frameless".into(),
        width: 800,
        height: 600,
        borderless: true,
        ..Default::default()
    };

    run_with_window_props(app, props, None);
}

Custom Title Bar with Window Controls

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::element::WindowProps;

#[component]
fn app() -> NodeHandle {
    rsx! {
        style {
            "
            * { margin: 0; padding: 0; box-sizing: border-box; }
            body {
                font-family: system-ui;
                background: #1e1e1e;
                color: white;
            }
            .titlebar {
                height: 32px;
                background: #2d2d2d;
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 0 8px;
                -webkit-app-region: drag;
            }
            .titlebar-buttons {
                display: flex;
                gap: 8px;
                -webkit-app-region: no-drag;
            }
            .titlebar-button {
                width: 12px;
                height: 12px;
                border-radius: 50%;
                border: none;
                cursor: pointer;
            }
            .close { background: #ff5f57; }
            .minimize { background: #febc2e; }
            .maximize { background: #28c840; }
            .content {
                padding: 16px;
                height: calc(100vh - 32px);
                overflow: auto;
            }
            "
        }
        div { class: "titlebar",
            span { "My App" }
            div { class: "titlebar-buttons",
                button {
                    class: "titlebar-button minimize",
                    onclick: || minimize_current_window()
                }
                button {
                    class: "titlebar-button maximize",
                    onclick: || toggle_maximize_current_window()
                }
                button {
                    class: "titlebar-button close",
                    onclick: || close_current_window()
                }
            }
        }
        div { class: "content",
            h1 { "Welcome" }
            p { "This window has a custom title bar." }
        }
    }
}
}

Window Control Functions

For custom window chrome, use the window control functions from the prelude:

#![allow(unused)]
fn main() {
use rinch::prelude::*;

// In event handlers:
button { onclick: || minimize_current_window(), "−" }
button { onclick: || toggle_maximize_current_window(), "□" }
button { onclick: || close_current_window(), "×" }
}

These functions work from onclick handlers and affect the window containing the element.

Fixed Overlays and Custom Chrome

When you draw your own titlebar (or use BorderlessWindow, or the Linux in-app menu bar), that chrome is an ordinary in-flow element — so normal content sits below it automatically, but a position: fixed overlay does not. Fixed elements resolve against the real viewport, exactly as in a browser, so a top: 0 drawer or modal renders underneath your titlebar and covers its close button.

BorderlessWindow and the menu bar publish their height as --rinch-window-top-inset for this. Offset full-height overlays by it:

#![allow(unused)]
fn main() {
div { style: "position: fixed; top: var(--rinch-window-top-inset, 0px); bottom: 0;" }
}

If you hand-roll the titlebar shown above, publish the variable yourself on a wrapper around your content so overlays inside it inherit the value:

#![allow(unused)]
fn main() {
div { style: "--rinch-window-top-inset: 36px;",
    // titlebar + content + any fixed overlays
}
}

Drawer, Modal, and top-anchored Notifications already consume it. See Theming → Window Chrome Inset.

Transparent Windows

For windows with transparency (useful for rounded corners or non-rectangular shapes):

use rinch::prelude::*;
use rinch_core::element::WindowProps;

#[component]
fn app() -> NodeHandle {
    rsx! {
        style {
            "
            body { background: transparent; }
            .window-content {
                background: rgba(30, 30, 30, 0.95);
                border-radius: 12px;
                margin: 8px;
                padding: 16px;
                height: calc(100vh - 16px);
            }
            "
        }
        div { class: "window-content",
            h1 { "Rounded Window" }
        }
    }
}

fn main() {
    let props = WindowProps {
        title: "Transparent".into(),
        width: 400,
        height: 300,
        borderless: true,
        transparent: true,
        resize_inset: Some(8.0),  // Enable resize handles
        ..Default::default()
    };

    run_with_window_props(app, props, None);
}

Custom Resize Handles

When creating borderless windows, native resize handles are not available. Rinch provides custom resize handle support through the resize_inset property.

The resize_inset value defines the distance (in logical pixels) from the window edges where resize handles are active. This is useful for transparent windows where the visible content is inset from the actual window edges for shadow effects.

How it works:

  • Resize handles are active within resize_inset + 8px from each edge
  • Corner resize areas are larger (resize_inset + 16px) for easier diagonal resizing
  • The cursor automatically changes to indicate resize direction when hovering edges
  • Only works when both borderless: true and resizable: true are set
  • On Windows, transparent areas don’t receive mouse events, so the resize detection only works on visible content

Example with shadow:

#[component]
fn app() -> NodeHandle {
    rsx! {
        style {
            "
            body { background: transparent; }
            .window {
                margin: 12px;  /* Same as resize_inset */
                background: #1e1e1e;
                border-radius: 8px;
                box-shadow: 0 4px 20px rgba(0,0,0,0.3);
                height: calc(100vh - 24px);
            }
            "
        }
        div { class: "window",
            "Your content here"
        }
    }
}

fn main() {
    let props = WindowProps {
        borderless: true,
        transparent: true,
        resize_inset: Some(12.0),  // Match CSS margin
        ..Default::default()
    };
    run_with_window_props(app, props, None);
}

Observing Window Resizes

There is no separate resize event. The supported way to observe viewport size changes is a root element’s bounds_signal(), which the runtime refreshes after every layout pass — including pure window resizes:

#![allow(unused)]
fn main() {
#[component]
fn app() -> NodeHandle {
    let root = __scope.create_element("div");
    root.set_attribute("style", "width: 100%; height: 100%");
    let bounds = root.bounds_signal(); // Signal<ElementBounds>

    // bounds.get().width / bounds.get().height track the viewport reactively.
    root
}
}

Programmatic Window Management

Open and close windows programmatically at runtime using the windows module.

Opening Windows

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::windows::{open_window, WindowHandle};
use rinch_core::element::WindowProps;

#[component]
fn app() -> NodeHandle {
    let settings_handle = Signal::new(None::<WindowHandle>);
    let handle_clone = settings_handle.clone();

    rsx! {
        button {
            onclick: move || {
                let handle = open_window(
                    WindowProps {
                        title: "Settings".into(),
                        width: 400,
                        height: 300,
                        ..Default::default()
                    },
                    "<h1>Settings</h1><p>Configure your app here.</p>".into()
                );
                handle_clone.set(Some(handle));
            },
            "Open Settings"
        }
    }
}
}

Closing Windows

#![allow(unused)]
fn main() {
use rinch::windows::close_window;

// Close a window by its handle
if let Some(handle) = settings_handle.get() {
    close_window(handle);
    settings_handle.set(None);
}
}

Window Builder Pattern

For more ergonomic window creation, use WindowBuilder:

#![allow(unused)]
fn main() {
use rinch::windows::WindowBuilder;

let handle = WindowBuilder::new()
    .title("Settings")
    .size(400, 300)
    .position(100, 100)
    .resizable(false)
    .content("<h1>Settings</h1>")
    .open();
}

Builder Methods

MethodDescription
title(impl Into<String>)Set window title
size(u32, u32)Set width and height
position(i32, i32)Set initial position
resizable(bool)Enable/disable resizing
borderless(bool)Remove window decorations
transparent(bool)Enable transparency
always_on_top(bool)Keep window above others
content(impl Into<String>)Set HTML content
open()Create the window and return handle

Complete Example

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::windows::{open_window, close_window, WindowBuilder, WindowHandle};

#[component]
fn app() -> NodeHandle {
    let dialogs = Signal::new(Vec::<WindowHandle>::new());
    let dialogs_open = dialogs.clone();
    let dialogs_close = dialogs.clone();
    let dialogs_display = dialogs.clone();

    rsx! {
        div {
            button {
                onclick: move || {
                    let handle = WindowBuilder::new()
                        .title(format!("Dialog {}", dialogs_open.get().len() + 1))
                        .size(300, 200)
                        .content("<p>A new dialog window!</p>")
                        .open();
                    dialogs_open.update(|v| v.push(handle));
                },
                "Open New Dialog"
            }
            button {
                onclick: move || {
                    if let Some(handle) = dialogs_close.get().last().copied() {
                        close_window(handle);
                        dialogs_close.update(|v| { v.pop(); });
                    }
                },
                "Close Last Dialog"
            }
            p { "Open dialogs: " {|| dialogs_display.get().len().to_string()} }
        }
    }
}
}

Window State Persistence

For applications that need to save and restore window positions and sizes, use the WindowState API.

Getting Window State

#![allow(unused)]
fn main() {
use rinch::windows::{get_window_state, WindowHandle, WindowState};

fn save_window_positions(handle: WindowHandle) {
    if let Some(state) = get_window_state(handle) {
        // state contains: x, y, width, height, maximized, minimized
        println!("Window at ({}, {}), size {}x{}",
            state.x, state.y, state.width, state.height);

        // Save to config file, database, etc.
        save_to_config("window", state);
    }
}
}

WindowState Fields

FieldTypeDescription
xi32X position (outer window position)
yi32Y position (outer window position)
widthu32Content area width
heightu32Content area height
maximizedboolWhether window is maximized
minimizedboolWhether window is minimized

Getting All Window States

#![allow(unused)]
fn main() {
use rinch::windows::get_all_window_states;

fn save_all_windows() {
    for (handle, state) in get_all_window_states() {
        // Save each window's state
        save_to_config(&format!("window_{}", handle.id()), state);
    }
}
}

Restoring Window State

When creating a window, pass the saved position and size to WindowProps:

#![allow(unused)]
fn main() {
use rinch::windows::WindowBuilder;

fn restore_window(saved: WindowState) -> WindowHandle {
    WindowBuilder::new()
        .title("Restored Window")
        .size(saved.width, saved.height)
        .position(saved.x, saved.y)
        .content("<p>Window restored!</p>")
        .open()
}
}

Note: Window state is automatically tracked and updated when windows are moved or resized. The state is available immediately after calling open_window() or WindowBuilder::open().


Rendering Backends

Rinch supports two rendering backends, selected at compile time:

GPU Mode (features = ["gpu"])

Windows are rendered using Vello, a GPU-accelerated 2D graphics library via wgpu. This provides:

  • Smooth animations
  • High-quality text rendering
  • Efficient GPU-accelerated repaints
  • Cross-platform consistency (Vulkan, Metal, DX12, WebGPU)

Software Mode (default)

Without the gpu feature, windows are rendered using tiny-skia (CPU rasterizer) and presented via softbuffer. This provides:

  • No GPU required — works in headless, CI, containers, SSH sessions
  • Full rendering fidelity (same visual output as GPU mode)
  • Dirty region caching — only changed areas are repainted for fast incremental updates
  • Subtree pruning — nodes outside the dirty region are skipped during paint

Menus

Rinch provides native menu support through the muda library. Menus use a unified builder API (Menu / MenuItem) shared between native window menus and system tray context menus.

Native Menus

Use run_with_menu to add a native menu bar to your window:

use rinch::prelude::*;
use rinch::menu::{Menu, MenuItem};

#[component]
fn app() -> NodeHandle {
    rsx! {
        div { "Application content" }
    }
}

fn main() {
    let file_menu = Menu::new()
        .item(MenuItem::new("New").shortcut("Ctrl+N").on_click(|| println!("New!")))
        .separator()
        .item(MenuItem::new("Quit").on_click(|| std::process::exit(0)));

    run_with_menu("My App", 800, 600, app, vec![("File", file_menu)]);
}

For apps without menus, use run("My App", 800, 600, app).

A container for menu items, separators, and submenus:

#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};

let menu = Menu::new()
    .item(MenuItem::new("Open"))
    .separator()
    .submenu("Recent", Menu::new()
        .item(MenuItem::new("file1.txt"))
        .item(MenuItem::new("file2.txt"))
    );
}

A clickable menu item with optional shortcut and callback:

#![allow(unused)]
fn main() {
use rinch::menu::MenuItem;

let item = MenuItem::new("Save")
    .shortcut("Ctrl+S")
    .enabled(true)
    .on_click(|| println!("Saving..."));
}
MethodTypeDescription
new(label)impl Into<String>Create a new item
shortcut(s)impl Into<String>Keyboard shortcut
enabled(e)boolWhether the item is clickable
on_click(cb)impl Fn() + 'staticCallback when activated

Callbacks are impl Fn() + 'static — no Send/Sync required. They always run on the main thread, so you can safely capture Signals:

#![allow(unused)]
fn main() {
use rinch::menu::MenuItem;

let count = Signal::new(0);

let item = MenuItem::new("Reset Counter")
    .on_click(move || {
        count.set(0);
        println!("Counter reset!");
    });
}

Callbacks fire both when the user clicks the menu item and when the keyboard shortcut is pressed.

Create nested menus using Menu::submenu:

#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};

let view_menu = Menu::new()
    .item(MenuItem::new("Zoom In").shortcut("Ctrl+="))
    .item(MenuItem::new("Zoom Out").shortcut("Ctrl+-"))
    .separator()
    .submenu("Appearance", Menu::new()
        .item(MenuItem::new("Light Theme"))
        .item(MenuItem::new("Dark Theme"))
    );
}

Keyboard Shortcuts

Shortcuts are specified as strings combining modifiers and a key, separated by +.

Modifiers

ModifiermacOSWindows/Linux
CmdCommandCtrl
CtrlControlCtrl
AltOptionAlt
ShiftShiftShift

Supported Keys

Letters: A through Z

Numbers: 0 through 9

Function keys: F1 through F12

Special keys:

  • Enter, Return
  • Escape, Esc
  • Backspace
  • Tab
  • Space
  • Delete, Del

Navigation:

  • Home, End
  • PageUp, PageDown
  • Up, Down, Left, Right (arrow keys)

Symbols:

  • =, Equal, Plus
  • -, Minus

Examples

#![allow(unused)]
fn main() {
MenuItem::new("New").shortcut("Ctrl+N")
MenuItem::new("Save As").shortcut("Ctrl+Shift+S")
MenuItem::new("Exit").shortcut("Alt+F4")
MenuItem::new("Zoom In").shortcut("Ctrl+=")
MenuItem::new("Find Next").shortcut("F3")
}

Shortcuts work across platforms - Cmd and Ctrl are automatically mapped to the platform-appropriate modifier.

Platform Behavior

macOS

On macOS, the menu appears in the system menu bar at the top of the screen, following Apple’s Human Interface Guidelines.

Windows

On Windows, the menu appears attached to the window’s title bar.

Linux

On Linux there is no native menu bar (muda needs a GTK window; winit uses raw X11/Wayland), so rinch renders the menu bar itself as ordinary DOM inside your window, 28px tall, from the same Menu/MenuItem API.

Because it lives in the document, it reserves its space with padding-top on a wrapper around your content — normal flow content clears it automatically.

A position: fixed element does not: fixed resolves against the real viewport, so a top: 0 overlay slides underneath the bar. The bar publishes its height as --rinch-window-top-inset, and full-height overlays should offset by it:

#![allow(unused)]
fn main() {
div { style: "position: fixed; top: var(--rinch-window-top-inset, 0px); bottom: 0;" }
}

Drawer, Modal, and top-anchored Notifications already handle this. See Theming → Window Chrome Inset.

Context Menus

Rendered Context Menu

Use the ContextMenu component for a styled, theme-aware context menu:

#![allow(unused)]
fn main() {
use rinch::prelude::*;

ContextMenu {
    ContextMenuTarget {
        div { "Right-click me" }
    }
    ContextMenuDropdown {
        DropdownMenuItem { onclick: || edit(), "Edit" }
        DropdownMenuItem { onclick: || duplicate(), "Duplicate" }
        DropdownMenuDivider {}
        DropdownMenuItem { color: "red", onclick: || delete(), "Delete" }
    }
}
}

The ContextMenu component automatically:

  • Wires up the oncontextmenu handler on the target
  • Positions the dropdown at the click coordinates using position: fixed
  • Shows an invisible overlay for click-outside-to-close
  • Reuses DropdownMenuItem and DropdownMenuDivider for consistent styling

oncontextmenu Event

The oncontextmenu prop is available on all HTML elements. It fires on right-click and provides mouse coordinates via get_click_context():

#![allow(unused)]
fn main() {
div {
    oncontextmenu: move || {
        let ctx = get_click_context();
        println!("Right-clicked at ({}, {})", ctx.mouse_x, ctx.mouse_y);
    },
    "Right-click target"
}
}

Complete Example

use rinch::prelude::*;
use rinch::menu::{Menu, MenuItem};

#[component]
fn app() -> NodeHandle {
    let file_path = Signal::new(None::<String>);
    let show_about = Signal::new(false);
    rsx! {
        div {
            h1 { "Application with Menus" }
            p {
                "Current file: "
                {|| file_path.get().unwrap_or_else(|| "Untitled".into())}
            }
            if show_about.get() {
                div {
                    h2 { "About My App" }
                    p { "Built with Rinch" }
                }
            }
        }
    }
}

fn main() {
    let file_path = Signal::new(None::<String>);
    let show_about = Signal::new(false);

    let file_menu = Menu::new()
        .item(MenuItem::new("New").shortcut("Ctrl+N").on_click(move || {
            file_path.set(None);
        }))
        .item(MenuItem::new("Open...").shortcut("Ctrl+O").on_click(move || {
            file_path.set(Some("example.txt".into()));
        }))
        .separator()
        .item(MenuItem::new("Save").shortcut("Ctrl+S").on_click(|| println!("Saving...")))
        .item(MenuItem::new("Save As...").shortcut("Ctrl+Shift+S"))
        .separator()
        .item(MenuItem::new("Exit").shortcut("Alt+F4"));

    let edit_menu = Menu::new()
        .item(MenuItem::new("Undo").shortcut("Ctrl+Z"))
        .item(MenuItem::new("Redo").shortcut("Ctrl+Shift+Z"))
        .separator()
        .item(MenuItem::new("Cut").shortcut("Ctrl+X"))
        .item(MenuItem::new("Copy").shortcut("Ctrl+C"))
        .item(MenuItem::new("Paste").shortcut("Ctrl+V"))
        .separator()
        .item(MenuItem::new("Select All").shortcut("Ctrl+A"));

    let help_menu = Menu::new()
        .item(MenuItem::new("Documentation"))
        .item(MenuItem::new("About").on_click(move || {
            show_about.update(|v| *v = !*v);
        }));

    run_with_menu("My App", 800, 600, app, vec![
        ("File", file_menu),
        ("Edit", edit_menu),
        ("Help", help_menu),
    ]);
}

Platform Features

Rinch provides optional platform integration features that can be enabled via Cargo features.

Image Loading

Images work out of the box for local files. Both <img> elements and background-image: url(...) CSS are supported. Images load asynchronously on background threads and render when decoded.

Local Files (built-in)

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div {
            // img element with object-fit
            Image { src: "photo.png", width: "200", height: "150", fit: "cover" }

            // Avatar with image
            Avatar { src: "avatar.png", size: "lg" }

            // background-image via CSS
            div { style: "width: 300px; height: 200px; background-image: url(photo.png); background-size: cover;" }
        }
    }
}
}

Supported formats: PNG, JPEG, GIF, WebP.

Network Images (optional)

Enable with: features = ["image-network"]

This adds HTTP(S) URL support using ureq. Non-URL paths fall back to local file loading.

#![allow(unused)]
fn main() {
// With image-network feature enabled, URLs work in src:
Image { src: "https://example.com/photo.jpg", width: "200", height: "150" }
Avatar { src: "https://example.com/avatar.png", size: "lg" }
}

How It Works

  1. When an <img> element or background-image: url(...) is encountered, the source is checked against an in-memory cache
  2. If not cached, loading is dispatched to a background thread via the ImageLoader trait
  3. The image bytes are decoded (using the image crate) into RGBA8 pixel data
  4. On the next layout pass, decoded images are picked up from a pending queue and inserted into the cache
  5. The element is marked dirty for re-layout/re-paint with the image’s intrinsic dimensions

Custom Image Loader

You can implement the ImageLoader trait for custom loading strategies (e.g., embedded assets, authenticated downloads):

#![allow(unused)]
fn main() {
use rinch_core::image::{ImageLoader, ImageLoadResult};

struct AssetLoader;

impl ImageLoader for AssetLoader {
    fn load(&self, src: &str) -> ImageLoadResult {
        match load_from_assets(src) {
            Ok(bytes) => ImageLoadResult::Loaded(bytes),
            Err(e) => ImageLoadResult::Failed(e.to_string()),
        }
    }
}
}

File Dialogs

Enable with: features = ["file-dialogs"]

Native file dialogs for opening, saving, and folder selection.

Opening Files

#![allow(unused)]
fn main() {
use rinch::dialogs::{open_file, MessageLevel};

// Open a single file with filters
if let Some(path) = open_file()
    .set_title("Select an image")
    .add_filter("Images", &["png", "jpg", "gif"])
    .add_filter("All Files", &["*"])
    .set_directory("/home/user/pictures")
    .pick_file()
{
    println!("Selected: {}", path.display());
}

// Open multiple files
if let Some(paths) = open_file()
    .add_filter("Rust Files", &["rs"])
    .pick_files()
{
    for path in paths {
        println!("Selected: {}", path.display());
    }
}
}

Saving Files

#![allow(unused)]
fn main() {
use rinch::dialogs::save_file;

if let Some(path) = save_file()
    .set_title("Save document")
    .set_file_name("untitled.txt")
    .add_filter("Text Files", &["txt"])
    .set_directory("/home/user/documents")
    .save()
{
    println!("Save to: {}", path.display());
}
}

Picking Folders

#![allow(unused)]
fn main() {
use rinch::dialogs::pick_folder;

if let Some(path) = pick_folder()
    .set_title("Select output folder")
    .pick()
{
    println!("Folder: {}", path.display());
}
}

Message Dialogs

#![allow(unused)]
fn main() {
use rinch::dialogs::{message, MessageLevel};

// Simple alert
message("File saved successfully!")
    .set_title("Success")
    .show();

// Warning with OK/Cancel
let proceed = message("This will overwrite existing files.")
    .set_title("Warning")
    .set_level(MessageLevel::Warning)
    .confirm();

if proceed {
    // User clicked OK
}

// Yes/No question
let delete = message("Are you sure you want to delete this file?")
    .set_title("Confirm Delete")
    .set_level(MessageLevel::Warning)
    .ask();

if delete {
    // User clicked Yes
}
}

Clipboard

Enable with: features = ["clipboard"]

Cross-platform clipboard support for text and images.

Text Operations

#![allow(unused)]
fn main() {
use rinch::clipboard::{copy_text, paste_text, has_text, clear};

// Copy text to clipboard
copy_text("Hello, clipboard!").unwrap();

// Check if clipboard has text
if has_text() {
    // Paste text from clipboard
    match paste_text() {
        Ok(text) => println!("Clipboard: {}", text),
        Err(e) => println!("Failed to paste: {}", e),
    }
}

// Clear the clipboard
clear().unwrap();
}

Image Operations

#![allow(unused)]
fn main() {
use rinch::clipboard::{copy_image, paste_image, has_image, ImageData};

// Copy an image (RGBA format)
let image = ImageData::new(
    100,  // width
    100,  // height
    vec![255; 100 * 100 * 4],  // RGBA data (white image)
);
copy_image(image).unwrap();

// Check and paste image
if has_image() {
    let image = paste_image().unwrap();
    println!("Image size: {}x{}", image.width, image.height);
    println!("Bytes: {}", image.bytes.len());
}
}

Using with Hooks

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::clipboard::{copy_text, paste_text};

#[component]
fn app() -> NodeHandle {
    let text = Signal::new(String::new());
    let text_copy = text.clone();
    let text_paste = text.clone();

    rsx! {
        div {
            input {
                value: {|| text.get()},
                oninput: move |e| text.set(e.value())
            }
            button {
                onclick: move || {
                    let _ = copy_text(text_copy.get());
                },
                "Copy"
            }
            button {
                onclick: move || {
                    if let Ok(pasted) = paste_text() {
                        text_paste.set(pasted);
                    }
                },
                "Paste"
            }
        }
    }
}
}

System Tray

Enable with: features = ["system-tray"]

System tray icon with menu support. Uses the same unified Menu/MenuItem types as native window menus.

Basic Tray Icon

#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};

// Create a tray menu using the unified Menu API
let menu = Menu::new()
    .item(MenuItem::new("Show Window").on_click(show_current_window))
    .separator()
    .item(MenuItem::new("Settings"))
    .separator()
    .item(MenuItem::new("Quit").on_click(close_current_window));

// Create the tray icon
let tray = TrayIconBuilder::new()
    .with_tooltip("My Application")
    .with_menu(menu)
    .build()
    .unwrap();
}

Tray Icon with Image

#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;

// From PNG data (e.g., include_bytes!)
let tray = TrayIconBuilder::new()
    .with_tooltip("My App")
    .with_icon_png(include_bytes!("../assets/icon.png"))?
    .build()?;

// From file path
let tray = TrayIconBuilder::new()
    .with_tooltip("My App")
    .with_icon_path("assets/icon.png")?
    .build()?;

// From RGBA data (32x32 icon)
let rgba = vec![255u8; 32 * 32 * 4]; // White icon
let tray = TrayIconBuilder::new()
    .with_tooltip("My App")
    .with_icon_rgba(rgba, 32, 32)?
    .build()?;
}

Callbacks are impl Fn() + 'static — no Send/Sync required. They run on the main thread via push-based event delivery (no polling):

#![allow(unused)]
fn main() {
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
use rinch::prelude::*;

let menu = Menu::new()
    .item(MenuItem::new("Show Window").on_click(show_current_window))
    .separator()
    .item(MenuItem::new("Quit").on_click(close_current_window));

let tray = TrayIconBuilder::new()
    .with_tooltip("My App")
    .with_icon_png(include_bytes!("../assets/icon.png"))?
    .with_menu(menu)
    .build()?;
}

Nested Submenus

#![allow(unused)]
fn main() {
use rinch::menu::{Menu, MenuItem};

let submenu = Menu::new()
    .item(MenuItem::new("Option 1"))
    .item(MenuItem::new("Option 2"))
    .item(MenuItem::new("Option 3"));

let menu = Menu::new()
    .item(MenuItem::new("Main Action"))
    .submenu("More Options", submenu)
    .separator()
    .item(MenuItem::new("Quit").on_click(close_current_window));
}

Minimize-to-Tray Pattern

Combine system tray with on_close_requested to hide instead of quit:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::tray::TrayIconBuilder;
use rinch::menu::{Menu, MenuItem};
use std::sync::Arc;

// Set up tray icon
let menu = Menu::new()
    .item(MenuItem::new("Show Window").on_click(show_current_window))
    .separator()
    .item(MenuItem::new("Quit").on_click(close_current_window));

let _tray = TrayIconBuilder::new()
    .with_tooltip("My App")
    .with_icon_png(include_bytes!("../assets/icon.png"))?
    .with_menu(menu)
    .build()?;

// Hide to tray on close instead of quitting
let window_props = WindowProps {
    on_close_requested: Some(Arc::new(|| {
        hide_current_window();
        false // Don't exit
    })),
    ..Default::default()
};
}

Enabling Features

Add features to your Cargo.toml:

[dependencies]
rinch = { version = "0.1", features = ["file-dialogs", "clipboard", "system-tray"] }

Platform Support

FeatureWindowsmacOSLinux
File Dialogs
Clipboard (Text)
Clipboard (Image)✓*
System Tray✓**

* Linux image clipboard requires X11 or Wayland clipboard support.

** Linux system tray requires a system tray implementation (e.g., libappindicator).

Game Engine Integration

Rinch supports two complementary integration patterns for game engines and custom renderers:

  1. Embed API — Your game owns the window and GPU. Rinch provides UI as a Vello scene you composite on top.
  2. RenderSurface — Rinch owns the window. Your game/renderer submits frames (CPU pixels or GPU textures) into a DOM component.

RenderSurface is a component that embeds external pixel content into rinch’s layout. Your renderer submits frames via a thread-safe SurfaceWriter (CPU pixels) or GpuTextureRegistrar (GPU textures), and rinch composites them during paint. Input events (mouse, keyboard) are routed back to your event handler.

This is the simpler pattern — rinch handles windowing, layout, and event dispatch. You just provide pixels and handle surface-local events.

Quick Start

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    let surface = create_render_surface();

    // Handle input events from the surface
    surface.set_event_handler(|event| {
        match event {
            SurfaceEvent::MouseDown { x, y, button } => { /* handle click */ },
            SurfaceEvent::MouseMove { x, y } => { /* handle hover */ },
            SurfaceEvent::MouseWheel { delta_y, .. } => { /* handle zoom */ },
            SurfaceEvent::KeyDown(key) => { /* handle keyboard */ },
            _ => {}
        }
    });

    // Submit frames from a worker thread
    let writer = surface.writer();
    std::thread::spawn(move || {
        loop {
            let pixels = render_frame(); // your renderer
            writer.submit_frame(&pixels, width, height);
            std::thread::sleep(std::time::Duration::from_millis(16));
        }
    });

    rsx! {
        div { style: "display: flex; height: 100%;",
            div { style: "width: 200px;",
                // Sidebar with rinch UI controls
                Button { onclick: || do_something(), "Tool" }
            }
            RenderSurface { surface: Some(surface), style: "flex: 1;" }
        }
    }
}

fn main() {
    run("My App", 1280, 720, app);
}

CPU Pixel Submission

SurfaceWriter is Send + Sync + Clone — safe to use from any thread:

#![allow(unused)]
fn main() {
let surface = create_render_surface();
let writer = surface.writer();

// From any thread:
let pixels: Vec<u8> = render_rgba(width, height);
writer.submit_frame(&pixels, width, height);
}

Pixels are RGBA8, row-major. The surface redraws automatically after submit_frame().

GPU Texture Compositing

For zero-copy compositing, use GpuTextureRegistrar to provide a wgpu::TextureView directly:

#![allow(unused)]
fn main() {
let surface = create_render_surface();
let registrar = surface.gpu_registrar();

// Get the shared wgpu Device via gpu_handle()
let gpu = gpu_handle().unwrap();
let device = &gpu.device;
let queue = &gpu.queue;

// Create your texture and render to it
let texture = device.create_texture(&wgpu::TextureDescriptor { /* ... */ });
// ... render into texture ...

// Register the texture view for compositing
let view = texture.create_view(&Default::default());
registrar.set_texture_source(view, width, height);
registrar.notify_frame_ready();
}

GpuTextureRegistrar is also Send + Sync + Clone. The texture must be created on the same wgpu::Device (available via gpu_handle()).

Use rinch::wgpu. rinch pins a patched wgpu fork. Construct every wgpu type you hand back to rinch (TextureView, config features/limits, …) from rinch::wgpu::…, not a separately-pinned wgpu dependency, or the types won’t match.

Sharing a High-Capability GPU Device

Zero-copy compositing requires your texture to live on the same device rinch composites with. By default rinch requests that device with Features::default() / Limits::default(). If your renderer needs more (extra features, larger storage buffers, more bind groups, …), raise the device’s requirements at startup so the shared device — the one published via gpu_handle() — can host your pipelines too.

Let rinch own the device (recommended). rinch still creates the instance, picks a surface-compatible adapter, and creates the device, so presentation is always correct — you only add the capabilities you need:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::wgpu;

let gpu = RinchGpuConfig {
    required_features: wgpu::Features::FLOAT32_FILTERABLE,
    required_limits: wgpu::Limits {
        max_storage_buffers_per_shader_stage: 32, // wgpu default is 8
        ..Default::default()
    },
};
run_with_gpu_config(app, WindowProps::default(), None, gpu);
}

After startup, gpu_handle() returns the shared device, queue, and adapter (use adapter.limits() to clamp, e.g. max_buffer_size = min(adapter, 2 GiB)). The requested features/limits are passed through verbatim — if the adapter can’t satisfy them, device creation fails loudly rather than silently dropping a capability.

Bring your own device. If you must keep your renderer’s exact DeviceDescriptor, build the whole GPU stack and hand it to rinch. rinch creates only the window surface (from your instance), validates that your adapter can present to it, and composites directly onto your device — no request_device, no CPU readback:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use rinch::prelude::*;

let gpu = ExternalGpu {
    instance,                    // rinch creates the window surface from this
    adapter: Arc::new(adapter),  // must be able to present to the window
    device: Arc::new(device),
    queue: Arc::new(queue),
};
run_with_external_device(app, WindowProps::default(), None, gpu);
}

The provided device is published through gpu_handle(). On a single-GPU machine a headless adapter (compatible_surface: None) usually presents fine; on multi-GPU systems create the adapter from a surface-compatible one, or prefer run_with_gpu_config (which guarantees compatibility). See the gpu-device-config example for both modes.

Layout Size

Query the surface’s current layout dimensions (set by CSS/Taffy) to match your render resolution:

#![allow(unused)]
fn main() {
let (w, h) = surface.layout_size();
// or from the registrar:
let (w, h) = registrar.layout_size();
}

Surface Events

Events are dispatched to the handler set via set_event_handler(). Coordinates are in logical pixels relative to the surface’s top-left corner.

EventFieldsDescription
MouseDownx, y, buttonMouse button pressed
MouseUpx, y, buttonMouse button released
MouseMovex, yMouse moved over surface
MouseWheelx, y, delta_x, delta_yScroll wheel
MouseEnterx, yCursor entered surface
MouseLeaveCursor left surface
KeyDownSurfaceKeyDataKey pressed (when focused)
KeyUpSurfaceKeyDataKey released (when focused)
TextInputStringText input (when focused)
FocusGainedSurface received keyboard focus
FocusLostSurface lost keyboard focus

SurfaceKeyData contains key, code, ctrl, shift, alt, meta.

Web (canvas viewport)

The same RenderSurface + create_render_surface() API works on rinch-web, but the model is inverted. On the web the browser composites, so rinch only creates and manages a real <canvas> “viewport hole” sized by layout; the app owns the GPU context (rinch links no wgpu on web). This mirrors desktop symmetrically: desktop = rinch owns the window and you submit frames; web = you own the canvas surface.

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::render_surface::{create_render_surface, RenderSurface};

let surface = create_render_surface();

// After mount, grab the <canvas> and create your own wgpu WebGPU surface on it.
// (canvas_element() returns None until the component has mounted.)
if let Some(canvas) = surface.canvas_element() {
    let wgpu_surface = instance
        .create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
    // request adapter/device (async on web), configure, and render each frame.
}

// Resize notification (ResizeObserver-driven). Size is PHYSICAL px
// (CSS px × devicePixelRatio), matching desktop — HiDPI-correct out of the box.
surface.set_resize_callback(move |w, h| reconfigure_wgpu_surface(w, h));

// Per-frame tick (drives requestAnimationFrame on web).
surface.set_render_callback(move |_writer, w, h| render_triangle(w, h));

rsx! { div { style: "flex:1;", RenderSurface { surface: Some(surface) } } }
}

Notes:

  • HiDPI out of the box. layout_size() reports physical pixels (CSS px × devicePixelRatio), rinch sizes the canvas backing store to match, and set_resize_callback pushes the new physical size on every resize so you can reconfigure your wgpu/WebGL surface.
  • Input (pointer / wheel / keyboard incl. KeyUp / focus) over the canvas is delivered to set_event_handler — rinch does not swallow it. Click the canvas to focus it for keyboard events.
  • CPU path still works & is portable. SurfaceWriter::submit_frame blits via a lazily-created 2D context; it self-disables once you claim a WebGPU/WebGL context. So the same app can run on desktop and web.
  • Clean teardown. The ResizeObserver and canvas listeners are removed when the component unmounts — no leaks.
  • See examples/webgpu-surface-web (a WebGPU triangle in a rinch DOM UI) — the web counterpart of examples/game-embed. Build with trunk serve over localhost (so navigator.gpu is available; the webgl cargo feature is a fallback).

API Reference: RenderSurface

Function / TypeDescription
create_render_surface()Create a new surface handle
RenderSurfaceHandleMain handle — set event handler, get writer/registrar
SurfaceWriterThread-safe CPU pixel submission (Send + Sync + Clone)
GpuTextureRegistrarThread-safe GPU texture registration (Send + Sync + Clone)
RenderSurfaceComponent — use in RSX with surface: Some(handle)
SurfaceEventInput event enum dispatched to handler

RenderSurfaceHandle methods:

MethodDescription
writer()Get a SurfaceWriter for CPU pixel submission
gpu_registrar()Get a GpuTextureRegistrar for GPU texture compositing
set_event_handler(handler)Set input event callback (main thread closure)
set_render_callback(cb)Per-frame FnMut(&SurfaceWriter, w, h) — drives requestAnimationFrame on web
set_resize_callback(cb)FnMut(w, h) fired on backing-size change (physical px) — reconfigure a GPU surface
layout_size()Get current physical (width, height) (web: CSS px × devicePixelRatio)
canvas_element()(web only) the <canvas> after mount — create a WebGPU/WebGL context on it
set_texture_source(view, w, h)(desktop, gpu) Set GPU texture directly (main thread only)
has_texture_source()Check if a GPU texture is registered
id()Surface ID
viewport_name()Internal viewport name

Embed API

The embed API is for when your game owns the window and wgpu device. Rinch runs headless — you feed it platform events, it produces a Vello scene, and you render/composite it yourself.

Enable with: features = ["desktop"]

Quick Start

use rinch::prelude::*;
use rinch::embed::{RinchContext, RinchContextConfig, RinchOverlayRenderer};

#[component]
fn game_hud() -> NodeHandle {
    let health = Signal::new(100);
    rsx! {
        div { style: "position: absolute; top: 10px; left: 10px;",
            Text { size: "lg", color: "white", {|| format!("HP: {}", health.get())} }
        }
    }
}

fn main() {
    // Your game creates the window and wgpu device
    let (device, queue, surface, window) = my_engine::init();

    // Create rinch UI context
    let mut ctx = RinchContext::new(
        RinchContextConfig {
            width: 1280,
            height: 720,
            scale_factor: window.scale_factor(),
            theme: None,
        },
        game_hud,
    );

    // Create overlay renderer from your device
    let mut overlay = RinchOverlayRenderer::new(
        &device, 1280, 720, wgpu::TextureFormat::Rgba8Unorm,
    );

    // Game loop
    loop {
        let events = collect_platform_events(&window);
        let actions = ctx.update(&events);

        for action in &actions {
            match action {
                AppAction::SetCursor(cursor) => { /* set cursor */ },
                AppAction::Exit => return,
                _ => {}
            }
        }

        game.render(&device, &queue);
        let ui_texture = overlay.render(&device, &queue, ctx.scene());
        composite(&device, &queue, &surface, game_texture, ui_texture);
    }
}

RinchContext

RinchContext is the main handle. Create it during initialization, call update() each frame.

Multiple contexts. Several RinchContexts can coexist on one thread — a screen HUD plus N render-to-texture panels, say. Each context tracks signal changes through its own subscription (so creating or dropping one never silences another) and keeps its own document-scoped element-bounds signals, editor registrations, and focus requests. Stores/contexts are namespaced per context (#136): a create_store inside a context’s component lands in that context’s own namespace, its effects and event handlers resolve that namespace first, and lookups fall back to the thread-global namespace for stores created outside any context (e.g. before mounting). Two contexts can therefore create the same store type without overwriting each other, and dropping a context clears its namespace. One caveat remains: each context still processes the input events you feed it — route each window’s events only to its own context.

Input Routing

For HUD overlays, use wants_mouse and wants_keyboard to decide whether input goes to the UI or the game:

#![allow(unused)]
fn main() {
if ctx.wants_mouse(mouse_x, mouse_y) {
    ctx.update(&[mouse_event]); // UI element under cursor
} else {
    game.handle_mouse(mouse_x, mouse_y); // game content
}

if ctx.wants_keyboard() {
    ctx.update(&[key_event]); // text input focused
} else {
    game.handle_key(key); // game shortcuts
}
}

Split Layout (Viewport Hole)

Use GameViewport to mark a transparent region where the game renders:

#![allow(unused)]
fn main() {
use rinch::embed::GameViewport;

#[component]
fn editor_ui() -> NodeHandle {
    rsx! {
        div { style: "display: flex; flex-direction: column; height: 100%;",
            div { class: "toolbar",
                Button { onclick: || save(), "Save" }
            }
            div { style: "display: flex; flex: 1;",
                div { style: "width: 200px;", /* side panel */ }
                GameViewport { name: "main", style: "flex: 1;" }
            }
        }
    }
}
}

Query the viewport rect to set your game’s render region:

#![allow(unused)]
fn main() {
if let Some(rect) = ctx.viewport_rect("main") {
    game.set_viewport(rect.x, rect.y, rect.width, rect.height);
}
}

Resize and Scale Factor

#![allow(unused)]
fn main() {
ctx.resize(new_width, new_height);
overlay.resize(&device, new_width, new_height);
ctx.set_scale_factor(window.scale_factor());
}

Platform Events

Translate your engine’s events to rinch_platform::PlatformEvent:

#![allow(unused)]
fn main() {
use rinch_platform::{PlatformEvent, MouseButton, KeyCode, Modifiers};

PlatformEvent::MouseMove { x: 100.0, y: 200.0 }
PlatformEvent::MouseDown { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseUp { x: 100.0, y: 200.0, button: MouseButton::Left }
PlatformEvent::MouseWheel { x: 100.0, y: 200.0, delta_x: 0.0, delta_y: -30.0 }
PlatformEvent::KeyDown {
    key: KeyCode::KeyA,
    text: Some("a".into()),
    modifiers: Modifiers::default(),
}
PlatformEvent::Resized { width: 1920, height: 1080 }
}

API Reference: Embed

RinchContext:

MethodDescription
new(config, component)Create and mount a rinch UI
update(&events) -> Vec<AppAction>Process events, update layout, return actions
scene() -> &SceneGet the Vello scene (lazy rebuild)
resize(w, h)Notify of window resize (physical pixels)
set_scale_factor(scale)Update DPI scale factor
set_theme(&props)Replace this context’s theme (restyles on the next update())
viewport_rect(name) -> Option<LayoutRect>Query a GameViewport’s computed rect
wants_mouse(x, y) -> boolTrue if point hits UI (not viewport hole)
wants_keyboard() -> boolTrue if a text input is focused
needs_update() -> boolTrue if UI needs repaint
register_font(data)Register font data for text rendering
app() / app_mut()Access the underlying RinchApp

RinchOverlayRenderer:

MethodDescription
new(device, w, h, format)Create from game’s wgpu device
render(device, queue, scene) -> TextureViewRender scene to texture
resize(device, w, h)Resize render target
texture()Get the underlying wgpu Texture

RinchContextConfig:

FieldTypeDescription
widthu32Initial viewport width (physical pixels)
heightu32Initial viewport height (physical pixels)
scale_factorf64Display scale factor
themeOption<ThemeProviderProps>Theme configuration

Theming is per-context. Each RinchContext owns the theme CSS generated from its config.theme (or the default theme when None) — creating a second context, or a shell app changing the thread-global theme, never restyles an existing context (issue #138). To change an embedded context’s theme at runtime, call ctx.set_theme(&props); the document restyles on its next update().

Caveat: an embedded ThemeProvider component with a reactive dark_mode_fn/primary_color_fn writes the thread-global theme slot, which embed contexts deliberately ignore — RinchContext::set_theme is the supported path for runtime theme changes in the embed API.

LayoutRect:

FieldTypeDescription
xf32Absolute X position (logical pixels)
yf32Absolute Y position (logical pixels)
widthf32Width (logical pixels)
heightf32Height (logical pixels)

Which Pattern to Use?

ScenarioUse
Adding UI overlay to an existing game engineEmbed API — game keeps its window/GPU ownership
Building a tool with embedded viewports (e.g., level editor, paint app)RenderSurface — rinch handles the window, you embed content
Terminal emulator, video player, or custom canvas inside a rinch appRenderSurface — component-level integration
WASM game with HTML-based UINeither — use the browser-native DOM backend

Examples

  • examples/game-embed/ — Spinning cube with rinch HUD overlay (embed API)
  • examples/render-surface-demo/ — Painting app with canvas and navigator (RenderSurface)
  • examples/webgpu-surface-web/(web) WebGPU triangle in a rinch DOM UI — the app owns a wgpu <canvas> viewport (RenderSurface on rinch-web)

Rich-text editing

Rinch ships a built-in rich-text editor — a ProseMirror-style, model-first editor with schema-enforced structure, inline marks, lists, tables, and exact undo/redo. You add it with the Editor {} component and drive it through an EditorHandle.

This page is the practical “how do I add an editor” guide. For the deeper model — the document tree, steps and transactions, the schema, commands, and the view seam — see the Rich-Text Editor guide.

Desktop today. The editor view is a desktop feature (gated behind the desktop cargo feature). The editor core is renderer-agnostic; a web view (over the browser’s native contentEditable) is a planned follow-up. There is no contenteditable HTML attribute and no DOM-level editing API to wire up — the Editor component is the whole surface.

Quick start

Create a handle with create_editor(), mount it with Editor {}, and dispatch named commands from toolbar buttons. Everything you need is in the prelude:

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn my_editor() -> NodeHandle {
    let editor = create_editor();

    // One cheap handle clone per closure that needs it (EditorHandle is an Rc).
    let ed_bold = editor.clone();
    let ed_italic = editor.clone();
    let ed_h1 = editor.clone();

    rsx! {
        div {
            // Toolbar — each button runs a named command.
            div {
                button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
                button { onclick: move || { ed_italic.command("toggleItalic"); }, "Italic" }
                button { onclick: move || { ed_h1.command("setHeading1"); }, "H1" }
            }
            // The editing surface — mounts the handle and renders its content.
            Editor {
                editor: editor.clone(),
                content: "<h1>Hello</h1><p>Type here…</p>",
            }
        }
    }
}
}

The Editor component renders the document, the caret, and the selection straight from the editor’s state. It ships its own default light/dark stylesheet, so the content looks right out of the box — you don’t hand-roll editor CSS.

The Editor component

PropTypePurpose
editorOption<EditorHandle>A handle from create_editor(). Omit it and the component creates its own self-contained editor.
contentStringInitial content as schema-whitelisted HTML, parsed into the document once on mount.

Like every rinch component, Editor {} also accepts the universal style: and class: props, applied to its host element.

#![allow(unused)]
fn main() {
// Self-contained: no handle needed if you don't drive it programmatically.
rsx! { Editor { content: "<p>Just some editable text.</p>" } }
}

The EditorHandle

create_editor() returns an EditorHandle — a cheap, cloneable (Rc) handle to a single editor. Clone one per closure that captures it; all clones share the same editor. It works before the editor is mounted or focused: state edits mutate the owned state and render when the view attaches.

Driving the editor: command

handle.command(name) dispatches a built-in command by name and returns whether it applied. This is the single entry point for toolbar buttons and menu items:

#![allow(unused)]
fn main() {
let ed = editor.clone();
button { onclick: move || { ed.command("toggleBold"); }, "Bold" }
}

Command names are case-sensitive. The full catalogue:

CategoryCommands
Inline markstoggleBold, toggleItalic, toggleUnderline, toggleStrike, toggleCode, toggleHighlight, toggleSubscript, toggleSuperscript
Block typessetParagraph, setHeading1setHeading6, setCodeBlock
AlignmentsetTextAlignLeft, setTextAlignCenter, setTextAlignRight, setTextAlignJustify
ContainerstoggleBulletList, toggleOrderedList, toggleTaskList, wrapInBlockquote
ListssinkListItem (indent), liftListItem (outdent)
InsertsinsertHorizontalRule, insertHardBreak, insertTable
TablesaddRowAfter, addRowBefore, addColumnAfter, addColumnBefore, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell
LinksremoveLink
Historyundo, redo

Links need a destination, so applying a link is a builder rather than a bare named command. Use handle.toggle_link(href) to add (or, over an existing link, remove) a link, handle.command("removeLink") to clear one unconditionally, and handle.active_link_href() to read the current link’s target for an edit dialog.

Alignment applies to the textblocks (paragraph / heading) overlapping the selection, including ones nested in lists, blockquotes, and table cells. Re-applying the alignment a block already has is a no-op, so a toolbar button bound to the current alignment stays inert. setParagraph (“reset to normal text”) clears alignment back to left along with the rest of the formatting.

Querying state

Toolbar “active” states and enablement read the editor state, never the DOM:

#![allow(unused)]
fn main() {
handle.is_mark_active("bold")          // -> bool: is bold active at the selection?
handle.current_block_type()            // -> Option<String>: e.g. Some("heading")
handle.in_node_type("bullet_list")     // -> bool: is the cursor inside a bullet list?
handle.can_run("liftListItem")         // -> bool: would this command apply right now?
}

A reactive toolbar button can read these inside a {|| ... } closure so it re-renders when the selection moves:

#![allow(unused)]
fn main() {
let ed = editor.clone();
Button {
    variant: {|| if ed.is_mark_active("bold") { "filled" } else { "subtle" }},
    onclick: move || { ed.command("toggleBold"); },
    "B"
}
}

Setting content

Pass HTML through the content: prop, or load it imperatively:

#![allow(unused)]
fn main() {
let editor = create_editor();

// Before or after mount — both work; the view renders the result either way.
editor.load_html("<h1>Loaded</h1><p>Programmatically set content.</p>");

rsx! { Editor { editor: editor.clone() } }
}
MethodPurpose
load_html(&str) -> boolParse schema-whitelisted HTML and replace the document. Returns false if it doesn’t parse into valid content.
doc() -> NodeThe current document (the save shape; serialize it under the serde feature).
insert_image(src, alt)Insert an image node (e.g. a data: URL), replacing the selection.
toggle_link(href) -> boolAdd a link mark with href across the selection, or remove it if the selection is already linked. No-op (returns false) for a collapsed cursor.
active_link_href() -> Option<String>The href of the link at the selection head, for pre-filling an “edit link” dialog. None when not inside a link.
replace_selection_with_html(&str)Replace the selection with parsed HTML (the rich-paste path).
selection_clipboard()The current selection serialized as (html, plain_text) for the clipboard.

HTML is schema-whitelisted on load: known block tags become nodes and known inline tags become marks; unknown tags and attributes (<script>, inline event handlers, …) are dropped at parse time. The document can only ever hold structure the schema allows.

Dark mode

The editor’s built-in stylesheet has light and dark color schemes; toggle between them with set_dark_mode:

#![allow(unused)]
fn main() {
let dark = Signal::new(false);
let ed = editor.clone();
button {
    onclick: move || {
        dark.update(|d| *d = !*d);
        ed.set_dark_mode(dark.get());
    },
    {move || if dark.get() { "Light mode" } else { "Dark mode" }}
}
}

Keyboard shortcuts

The editor handles its own keyboard input. Every shortcut below comes from the editor-core keymap (BaseCommandsPlugin/HistoryPlugin), which both the desktop and web views consult through one path — add a binding there and it works on every platform. Mod = Ctrl on Windows/Linux, Cmd on macOS.

ShortcutAction
Mod+B / Mod+I / Mod+UToggle bold / italic / underline
Mod+EToggle inline code
Mod+Shift+SToggle strikethrough
Mod+ASelect all
Mod+Alt+1…6Heading 1–6
Mod+Shift+0Paragraph
Mod+Shift+7 / 8 / 9Task / bullet / ordered list
Mod+Shift+BBlockquote
Mod+Z / Mod+Shift+Z / Mod+YUndo / redo
EnterSplit block / new list item
Shift+EnterInsert a hard break (line break within the block)
Tab / Shift+TabMove between table cells, else indent / outdent a list item
Backspace / DeleteDelete backward / forward

(Copy/cut/paste — Mod+C/X/V, and Mod+Shift+V for paste-as-plain — are handled by the platform clipboard, not the keymap.) Undo/redo is a single, exact history: each undo reverses one logical edit (typing is merged into a group), because every edit is an invertible step.

Markdown shortcuts

As you type, the editor rewrites markdown shortcuts in place (the default MarkdownInputRulesPlugin, on by default). Block shortcuts fire on a space at the start of a line; inline mark shortcuts fire when you type the closing delimiter:

TypeBecomes
# ###### Heading 1–6
```Code block
> Blockquote
- / * / + Bullet list
1. Ordered list
[ ] / [x] Task list (unchecked / checked)
**bold** / __bold__bold
*italic* / _italic_italic
~~strike~~strike
==highlight==highlighted
`code`inline code

Inside a task list, Enter adds a new (unchecked) item, and Enter on an empty item exits the list — just like bullet/ordered lists. Click a task’s checkbox to toggle it done (works on desktop and web). To add your own shortcut, append a mark_input_rule / wrapping_input_rule / textblock_type_input_rule to markdown_input_rules() (or contribute an input_rules() set from your own plugin).

A complete toolbar

A full example pairing a command toolbar with the editor lives at examples/markdown-editor/src/main.rs, and examples/ui-zoo/src/sections/editor.rs shows the same pattern inside the component showcase. The shape is always: create_editor() once, clone the handle into each button’s onclick, and place a single Editor {} for the surface.

On the web (rinch-web)

The editor runs in the browser too — the same Editor {} / EditorHandle / create_editor(), with identical app code. The renderer-agnostic view lives in rinch-editor-view and projects onto rinch-web’s web_sys DOM (the model is the single source of truth; the container is deliberately not contenteditable). rinch-web re-exports the editor, so a web app just imports it:

#![allow(unused)]
fn main() {
use rinch_web::{Editor, create_editor};

#[component]
fn app() -> NodeHandle {
    let editor = create_editor();
    let ed_bold = editor.clone();
    rsx! {
        div {
            button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
            Editor { editor: editor.clone(), content: "<p>Edit me in the browser.</p>" }
        }
    }
}

#[wasm_bindgen(start)]
pub fn start() {
    rinch_web::mount(ThemeProviderProps::default(), app);
}
}

A runnable demo is examples/editor-web (built with trunk serve). The browser build links no rinch-dom/Parley/automerge — the browser handles layout, text, and painting.

Supported today: typing, the full command/toolbar surface, keyboard shortcuts, caret + selection rendering (pixel-accurate overlays), click / double-click (word) / triple-click (block) / shift-click / drag selection, arrow / word / Home-End / vertical navigation, clipboard (copy / cut / paste — rich text/html, image, or plain text), and IME composition (the preedit overlay matches the composing block’s font).

Clipboard and IME ride a focused, off-screen hidden <textarea> capture target (created on first editor focus): a plain non-contenteditable <div> receives no paste/cut/compositionstart events, so the editor focuses the textarea to make the browser route those native events to it — which also makes focus browser-native so keys can’t reach the wrong control. Typed characters are still consumed by the editor’s key handler (and never reach the textarea); only IME composition flows through it. This mirrors the CodeMirror / ProseMirror hidden-input technique.

Collaboration (optional, collaboration feature)

Two editors can share one live document. Enable the collaboration feature and the editor projects every local edit onto an Automerge CRDT, broadcasts the resulting delta, and rebuilds the model from a peer’s delta when one arrives — so concurrent edits converge. The CRDT adapter (rinch-editor-collab) is the only thing in a rinch app that links automerge; default builds link none of it.

rinch = { workspace = true, features = ["desktop", "collaboration"] }

One peer hosts (it owns the starting document); the others join from a snapshot of it. Each side supplies an outbound closure — where to send a delta a local edit produced — and feeds a peer’s delta back in with collab_receive:

#![allow(unused)]
fn main() {
// Host: project the current document onto a fresh CRDT and hand peers a snapshot.
let snapshot = host.start_collaboration_host(move |delta| transport.send(delta))?;

// Guest: adopt the host's document and start collaborating.
guest.start_collaboration_guest(&snapshot, move |delta| transport.send(delta))?;

// When a delta arrives from the network, apply it on the main thread:
guest.collab_receive(&delta_bytes);
}

The transport is yours to pick — the seam is just bytes in and bytes out — and it should deliver deltas reliably and in order (the seam carries incremental Automerge changes; the full sync protocol for lossy/out-of-order resync is not yet exposed through the handle). From a background socket/data-channel thread, use the Send-safe entry point, which marshals the delta onto the main thread for you:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
post_remote_delta(editor_container_id, delta_bytes); // any thread → main
}

is_collaborating(), stop_collaboration(), collab_snapshot() (a fresh snapshot for a late-joining peer to start_collaboration_guest from), and collab_take_error() round out the API. The first milestone covers flat text-blocks + marks (paragraphs, headings, code blocks, bold/italic/link/…); an edit outside that scope fails loud rather than silently diverging — collab_take_error() surfaces it, and the CRDT is left untouched (the local edit is not projected). A runnable two-pane loopback (both editors in one window, no network) lives at examples/collab-editor-demo/src/main.rs.

On the web

The same adapter runs in the browser — Automerge compiled to wasm. Enable the collaboration feature on rinch-web:

rinch-web = { path = "...", features = ["collaboration"] }

The EditorHandle collab API is identical to desktop, with two web specifics:

  • Inbound is a direct call. Wasm is single-threaded, so a transport callback (e.g. a WebSocket onmessage) already runs on the main thread — call handle.collab_receive(&bytes) (or collab_receive_for(container_id, &bytes)) directly. There is no post_remote_delta on web (that is the desktop runtime’s off-thread marshaller).
  • Randomness. Automerge mints actor ids via uuid, which needs a randomness source on wasm32-unknown-unknown. Add uuid = { version = "1", features = ["js"] } to your app (it routes to the Web Crypto API); without it the wasm build won’t compile. rinch-web deliberately does not pin this — it is the app’s choice.

A runnable two-pane web loopback is examples/collab-editor-web (built with trunk serve), the browser counterpart of collab-editor-demo.

Where to go next

  • Rich-Text Editor — the document model, schema, transactions, commands, history, and the view seam in depth.
  • examples/markdown-editor — a standalone editor app (great for MCP-driven iteration; built with the debug feature).
  • examples/collab-editor-demo — two editors sharing one CRDT, live.

Rich-Text Editor

Rinch’s rich-text editor is a ProseMirror-faithful architecture in idiomatic Rust: a pure, renderer-agnostic core (rinch-editor-core) plus a desktop view that projects it onto rinch-dom. This page is the conceptual guide to that core — the document model, schema, steps and transactions, state, commands, history, and the view seam.

Just adding an editor to a screen? Start with the Rich-text editing guide — create_editor(), the Editor {} component, and the command API. This page is the layer underneath it.

The one big idea

There is exactly one source of truth and exactly one way to change it:

  1. EditorState is a value{ doc, selection, stored_marks, plugin_state }. No DOM node is ever authoritative.
  2. Every edit is a Transaction of invertible Steps. state.apply(tr) returns a new EditorState; it is pure and side-effect-free.
  3. The view is a pure function of state. The desktop view diffs the old and new document and patches the host tree; it renders the caret and selection from state.selection. The host tree is never read back for content.
  4. Input produces transactions, not DOM edits. Keys, IME, paste, and pointer selection are translated into commands/transactions.
  5. The schema is authoritative and enforced. Transactions are validated; invalid steps are rejected, never silently written. Serialization is total and attr-aware — no mark or node type can be dropped.

Because the host is derived from the model on every transaction, “the DOM and the model disagree” is structurally impossible.

The document model

The model is a persistent (structurally-shared) immutable tree. Cloning a Node is a cheap Rc bump; every edit produces a new tree that shares unchanged subtrees with the old one. That is what makes states cheap to keep in history and cheap to diff in the view.

Node, Mark, Fragment, Slice

  • Node — a value in the tree. It has a schema node type, typed attrs, a Fragment of children (empty for leaves), marks (on inline/text leaves), and optional text (for the text node). hard_break, horizontal_rule, and image are first-class nodes, not strings.
  • Mark — an inline annotation (bold, italic, link{href}, …) carried on text and inline leaves, with typed attrs.
  • Fragment — an ordered, sized, Rc-shared child list with the cut / append / replace primitives the transform engine needs.
  • Slice{ content: Fragment, open_start, open_end }. The open depths let a copied or pasted range merge into the surrounding structure; it is the unit that replace and paste operate on.
#![allow(unused)]
fn main() {
use rinch::prelude::*;            // Node, Mark, Slice, Schema, Selection, Pos, …

let doc = editor.doc();           // the current document Node (the save shape)
assert!(doc.child_count() >= 1);
}

One position space

The editor uses ProseMirror’s single depth-aware integer position space, Pos:

  • Each node contributes 1 for its opening boundary and 1 for its closing boundary.
  • Text contributes one position per Unicode scalar value (char), not per byte — byte offsets exist only transiently at platform seams (Parley layout, IME).
  • The document root starts at 0; doc.content_size() is the last valid position.

Pos resolves to a ResolvedPos that answers parent(), node(depth), before(depth)/after(depth), index(depth), and marks() — replacing flat block/inline/offset math with one consistent model.

Schema and ContentMatch

The schema defines which nodes and marks exist and what content each node may contain. The starter-kit catalogue:

  • Nodes: doc, paragraph, heading{level}, blockquote, code_block, bullet_list, ordered_list, list_item, horizontal_rule, hard_break, text, image{src, alt}, plus the table nodes.
  • Marks: bold, italic, underline, strike, code, link{href}, highlight{color?}, text_color{color}, subscript, superscript.

Each node spec carries a content expression (e.g. blockquote > block+, list_item > block+, bullet_list > list_item+). These compile to a ContentMatch NFA that the transform engine consults to decide whether a step’s result is valid — matchType, matchFragment, and fillBefore drive both validation and the automatic insertion of required nodes. Required attrs (link.href, image.src, heading.level) are applied/enforced at the step boundary, so attr-aware round-trip is structural, not best-effort.

Serialization

The durable save/load shape is a recursive, schema-derived structure (under the serde feature). Serialize walks the schema, so every node type and mark type has a name and there is no string-tag fallthrough; deserialize consults the schema and rejects unknown types rather than dropping them silently. HTML serialization derives tags from the schema’s parse_html_tags, so copy-out and paste-in share one table — the same one Editor’s content: prop and load_html use.

The transform engine

Every editing operation is a Transaction carrying one or more Steps. Steps are invertible (for undo) and mappable (for redo, collaboration rebase, and decoration tracking).

A deliberately minimal Step set

There is not a step per gesture. A small primitive set expresses everything; the gestures map onto it:

StepPurpose
ReplaceStep { from, to, slice }Replace a range with a Slice. The workhorse — text insert, delete, split, join, and paste all reduce to this.
ReplaceAroundStep { … gap …, slice }Replace around a preserved gap — wrapping (blockquote/list), lifting, and re-parenting block-type changes.
AddMarkStep { from, to, mark }Add a mark across an inline range.
RemoveMarkStep { from, to, mark }Remove a mark across an inline range.
SetNodeAttrStep { pos, attr, value }Change one node attr (heading level, image alt, list start).
SetDocAttrStep { attr, value }A document-level attr.

Tables and collaboration add no new step kinds — table edits are Replace / ReplaceAround / SetNodeAttr over the table, row, and cell nodes.

A few gesture → step mappings:

GestureStep(s)
Type a characterReplaceStep(from, to, Slice::text(ch, stored_marks))
Backspace mid-textReplaceStep(pos-1, pos, empty)
Enter / split blocka ReplaceStep that splits the textblock (open slice + new block)
Toggle bold over a rangeAddMarkStep / RemoveMarkStep over from..to
Toggle bold at a cursorno step — set stored_marks (applied to the next typed text)
Wrap in blockquotewrap(range, [(blockquote, {})])ReplaceAroundStep
Pasteparse to a Slice, then ReplaceStep(from, to, slice)

Step::apply is where schema enforcement lives: a ReplaceStep whose slice would violate the parent’s ContentMatch returns an error and the whole transaction is rejected. Invert is mechanical; map rebases positions through a Mapping; merge coalesces consecutive single-char replaces so typing groups naturally.

Transactions

A Transaction accumulates steps (each applied into a running doc), maps the selection forward as steps are added, and carries stored_marks and plugin meta. You rarely build one by hand — commands do — but the shape is:

#![allow(unused)]
fn main() {
// Inside a command, or via EditorHandle::update:
let mut tr = state.tr();
tr.insert_text("hello").ok()?;     // a ReplaceStep
tr.set_selection(Selection::cursor(Pos(/* … */)));
Some(tr)                           // dispatched and applied by state.apply
}

State, selection, stored marks

EditorState { doc, selection, stored_marks, schema, plugins, plugin_state }

state.apply(tr) runs the transaction’s steps, then folds each plugin’s state forward (history pushes inverted steps, decoration sets remap through the mapping). It returns a brand-new state; nothing is mutated in place, no DOM is touched.

Selection is part of state and is mapped forward by every transaction. It is one of:

  • Text { anchor, head } — a text caret/range (anchor fixed, head moving).
  • Node { pos } — a whole atom selected (an image, a horizontal rule).
  • Cell { anchor_cell, head_cell } — a table-cell rectangle.

The caret is rendered from the selection by the view — there is no second cursor model.

Stored marks carry a tri-state for the “click Bold then type” case:

  • None → inherit marks from the position context on the next insert.
  • Some(vec![]) → explicitly no marks.
  • Some([bold]) → the next inserted text gets bold.

Toggling a mark at a collapsed cursor sets stored_marks; toggling over a range emits an AddMark/RemoveMark step.

Commands, keymap, input rules

A command queries the state and, if it applies, builds a transaction and dispatches it — returning true. Called for applicability only, it reports whether it would apply (driving toolbar enabled/disabled state). Toolbar queries read state, never the DOM:

#![allow(unused)]
fn main() {
editor.command("toggleBold");          // dispatch by name
editor.can_run("liftListItem");        // would it apply? (enablement)
editor.is_mark_active("bold");         // toolbar "on" state
editor.current_block_type();           // e.g. Some("heading")
editor.in_node_type("bullet_list");    // ancestor-aware (lists, blockquote)
}

The built-in command catalogue is listed in the Rich-text editing guide: mark toggles, block-type setters, list/blockquote wrapping, indent/outdent, inserts, the full table command set, and undo/redo.

The keymap is the single source of truth for command keys. Each platform view translates its native event into a platform-agnostic KeyBinding and routes it through one entry point, EditorHandle::dispatch_key, which looks up the aggregated Keymap and runs the bound command (so Mod-btoggleBold everywhere). Letters resolve by the logical key (winit’s layout-mapped logical_key on desktop, event.key() on web), so Mod-b follows the keycap on Dvorak/AZERTY; digits and symbols resolve by the physical key (KeyCode / event.code()), so Mod-Shift-8 matches the 8 key regardless of the shifted glyph. Only keys that can’t be pure editor-core commands stay view-owned: cursor movement (needs laid-out geometry), clipboard (needs the platform clipboard), and plain text insertion. Input rules are regex-driven transforms — block shortcuts like ## → heading, - → bullet list, [ ] → task list, and inline mark shortcuts like **bold** / ==highlight== / `code` — each returning an optional transaction. The view runs apply_input_rules inside EditorHandle::insert_text (before the plain insert) on every text-entry path, so a just-typed character can complete a shortcut and rewrite the text instead of being inserted verbatim (ProseMirror’s inputRules plugin). They only fire at a collapsed cursor; paste and IME preedit don’t reach this path (an IME commit does).

History

There is one history, implemented as a plugin. It stores inverted steps (not byte positions), groups them by transaction boundary, and merges consecutive typing transactions within a time/affinity window, so a burst of typing undoes as one step. On undo it pops a group, rebases its inverted steps over any intervening mappings, applies them as a transaction that doesn’t re-enter history, and restores the recorded selection. undo / redo are the only history entry points.

Plugins

History, tables, links, input rules, and (later) collaboration and accessibility are all plugins — none is special-cased in the core. A plugin can contribute schema nodes/marks, commands, keymap bindings, input rules, per-document state, decorations (preedit overlay, selection rectangle, search highlight), and node-views. This is how features compose without bloating the core.

The view seam

The core defines an EditorView trait and a small request/event vocabulary; it knows nothing about any renderer. The desktop view lives in the rinch crate — the only rinch-dom touchpoint — and a web view (a follow-up) will live in rinch-web, delegating to the browser’s native contentEditable. Nothing else knows the renderer.

The desktop view, on each transaction:

  1. Diffs prev.doc against next.doc. Because the model is persistent, Rc::ptr_eq makes the diff cheap — unchanged subtrees are skipped entirely.
  2. Patches rinch-dom for the changed regions via the standard primitives (create_element / create_text / append_child / insert_before / remove / set_text / set_attribute / set_style), choosing tags from the schema-driven serializer. Leaf node-views (image, horizontal rule, table cell) own their own subtree; the diff stops at their boundary and delegates.
  3. Renders the caret and selection from state.selection (after layout), converting the model’s char Pos to a byte offset for Parley only at this render edge.

The view also owns block virtualization (skipping layout for off-screen blocks while keeping their real height), IME positioning, and — under the opt-in a11y feature — deriving an accessibility tree from the state.

End-to-end edit flow

key / IME / paste / pointer event
  → the view translates it to a command call or a transaction
  → command(state, dispatch): queries state, builds tr, dispatches it
  → new_state = state.apply(tr)         // schema-validated; reject ⇒ no-op
       • steps applied, selection mapped forward
       • plugins fold state (history pushes inverse, decorations remap)
  → view.update(old_state, new_state)   // diff doc → minimal host patches
       • caret/selection rendered from new_state.selection

No step in this pipeline reads the host tree for content. That is the whole design.

Persisting content

handle.doc() returns the current document Node — the canonical save shape. Enable the serde feature (rinch-editor-core/serde, or serde on the rinch facade) to serialize it to the recursive, schema-derived wire shape and load it back; unknown types are rejected on load, never silently dropped.

Where to go next

  • Rich-text editing — the practical component + command API.
  • examples/markdown-editor and examples/ui-zoo/src/sections/editor.rs — working editors driving the command API.
  • Editor Architecture — deeper technical notes.

Architecture Overview

Rinch is a lightweight cross-platform GUI library for Rust using fine-grained reactive rendering. The architecture emphasizes surgical DOM updates rather than full re-renders, with platform abstraction for desktop and web targets.

Core Principle: Fine-Grained Reactivity

Signal.set()
    → Effect runs
    → NodeHandle.set_text() / set_attribute() / set_style()
    → Direct RinchDocument mutation
    → mark_dirty() for re-layout

Key insight: app() runs once to build the DOM. Effects handle all reactive updates surgically. No HTML regeneration for simple updates.

Crate Structure

rinch/
├── crates/
│   ├── rinch/              # Main application crate
│   │   ├── src/app.rs      # Platform-agnostic RinchApp
│   │   └── src/shell/      # Desktop backend (winit + wgpu)
│   ├── rinch-core/         # Core types, reactive primitives, DOM abstractions
│   ├── rinch-macros/       # rsx! proc macro
│   ├── rinch-dom/          # HTML/CSS DOM (Taffy + Parley + Stylo + Vello)
│   ├── rinch-platform/     # Platform abstraction traits
│   ├── rinch-web/          # Browser-native DOM backend (WebDocument over web_sys)
│   ├── rinch-editor/       # Rich-text editor (CRDT)
│   ├── rinch-theme/        # Theme system (CSS variables)
│   ├── rinch-components/   # UI component library (~55 components)
│   ├── rinch-editable/     # Text editing abstractions
│   ├── rinch-clipboard/    # Cross-platform clipboard
│   ├── rinch-tabler-icons/ # 5000+ Tabler Icons
│   ├── rinch-debug/        # Debug IPC server
│   └── rinch-mcp-server/   # MCP server for Claude
└── examples/
    ├── ui-zoo-desktop/     # Desktop component showcase + rich-text editor
    └── ui-zoo-web/         # Web (WASM) component showcase using browser-native DOM

Layer Diagram

┌──────────────────────────────────────────────────────────────┐
│                     Application Layer                         │
│  (your app, ui-zoo-desktop, ui-zoo, etc.)                         │
├──────────────────────────────────────────────────────────────┤
│                         rinch                                 │
│  ┌──────────────────────────────────────────────────┐        │
│  │          RinchApp (app.rs)                        │        │
│  │  Platform-agnostic application logic              │        │
│  │  handle_event(PlatformEvent) -> Vec<AppAction>    │        │
│  └──────────────────────────────────────────────────┘        │
│                          │                                    │
│         ┌────────────────┼────────────────┐                  │
│         ▼                ▼                ▼                   │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          │
│  │  Desktop    │  │   Web       │  │  (future)   │          │
│  │ winit+wgpu  │  │  web-sys    │  │  mobile     │          │
│  └─────────────┘  └─────────────┘  └─────────────┘          │
├──────────────────────────────────────────────────────────────┤
│                    rinch-platform                             │
│  PlatformWindow, PlatformRenderer, PlatformEventLoop         │
│  PlatformEvent, AppAction, KeyCode, Modifiers                │
├──────────────────────────────────────────────────────────────┤
│                      rinch-core                               │
│  Signal, Effect, Memo, RenderScope, NodeHandle, DomDocument  │
├──────────────────────────────────────────────────────────────┤
│  rinch-dom    │  rinch-theme  │  rinch-components            │
│  (HTML/CSS)   │  (CSS vars)   │  (~55 components)            │
├──────────────────────────────────────────────────────────────┤
│                    External Crates                            │
│  Taffy (layout) │ Parley (text) │ Stylo (CSS) │ Vello/tiny-skia │
└──────────────────────────────────────────────────────────────┘

Cross-Platform Architecture

Rinch uses a platform abstraction pattern to support multiple backends (desktop, web, mobile):

#![allow(unused)]
fn main() {
// Platform-agnostic application logic
impl RinchApp {
    fn handle_event(&mut self, event: PlatformEvent) -> Vec<AppAction> {
        match event {
            PlatformEvent::MouseDown { x, y, button } => {
                // Handle click, update DOM
                vec![AppAction::RequestRedraw]
            }
            PlatformEvent::KeyPress { key, modifiers } => {
                // Handle keyboard input
                vec![AppAction::RequestRedraw]
            }
            // ...
        }
    }
}
}

Platform backends implement traits from rinch-platform:

  • PlatformWindow - Window creation, properties, frame buffer access
  • PlatformRenderer - GPU rendering (wgpu for desktop, browser-native DOM for web)
  • PlatformEventLoop - Event loop integration
  • PlatformMenu - Native menu support

Event flow: Platform backend → PlatformEventRinchApp.handle_event()Vec<AppAction> → Platform backend

This separation allows:

  • Core app logic to be platform-agnostic
  • Easy testing with mock platforms
  • Adding new platforms by implementing the traits
  • Sharing code between desktop and web builds

Key Components

rinch-core

The foundation layer containing:

  • Reactive primitives - Signal<T>, Effect, Memo<T> for state management
  • DOM abstractions - RenderScope, NodeHandle, DomDocument trait
  • Reactive Primitives - Signal::new(), Effect::new(), Memo::new(), create_context()
  • Element types - Minimal enum: Html, Fragment, Component only
  • Event handling - Input and click event dispatch
  • Icon enum - Curated set of ~40 common icons for components

rinch-macros

The #[component] attribute macro and rsx! proc macro that generate DOM construction code:

#![allow(unused)]
fn main() {
// This RSX syntax:
rsx! {
    div { class: "container",
        p { "Count: " {|| count.get().to_string()} }
    }
}

// Generates code that:
// 1. Creates DOM nodes via RenderScope
// 2. Sets up Effects for reactive expressions {|| ...}
// 3. Wires event handlers
}

rinch

The main crate that ties everything together:

  • RinchApp (app.rs) - Platform-agnostic application logic
  • Desktop backend (shell/rinch_runtime.rs) - Event loop, window creation, rendering
  • Event loop (shell/rinch_runtime.rs) - Desktop runtime: event loop, window creation, rendering
  • Menu Manager - Native menu support via muda

rinch-platform

Platform abstraction layer defining cross-platform traits:

  • Traits - PlatformWindow, PlatformRenderer, PlatformEventLoop, PlatformMenu
  • Types - PlatformEvent, AppAction, KeyCode, Modifiers, MouseButton

rinch-dom

HTML/CSS DOM implementation:

  • RinchDocument - Implements DomDocument trait from rinch-core
  • Layout - Taffy for flexbox/grid layout
  • Text - Parley for text shaping and line breaking
  • Styling - Stylo for CSS parsing and computed styles
  • Rendering - Vello (GPU) or tiny-skia (software) via the Painter trait abstraction

rinch-web

Browser-native DOM backend (the WASM target). Consumed by examples/ui-zoo-web and examples/paint-web:

  • WebDocument - Implements DomDocument via web_sys, creating real browser DOM elements
  • No Taffy/Parley/Vello - The browser handles layout, text shaping, and painting natively
  • Event delegation - setup_event_delegation installs document-level listeners that dispatch via data-rid attributes (plus drag, render-surface routing, and data-onsubmit/data-oninput)
  • mount helper - One call wires the WebDocument, builds the component tree, installs event delegation, and injects theme CSS
  • Smaller binary - ~3.2MB WASM (vs 11MB+ with Vello rendering)

rinch-theme

Theme system with CSS variables:

  • Color palettes (10 shades per color, Mantine-inspired)
  • Spacing, radius, typography scales
  • Dark mode support
  • CSS variable generation for components

rinch-editor

Rich-text editor with collaborative editing support:

  • CRDT-backed document - Automerge for offline editing and conflict resolution
  • Schema system - Define valid document structure with nodes and marks
  • 22 StarterKit extensions - Complete editing experience out of the box
  • Command system - All mutations go through named commands
  • Extension system - Add custom nodes, marks, commands, shortcuts
  • Keyboard shortcuts - 16+ built-in shortcuts (Mod-B, Mod-I, etc.)
  • Markdown input rules - Auto-convert markdown patterns (# heading, bold, etc.)
  • Table editing - Full table support with merge/split/navigation
  • Local undo/redo - Compatible with collaborative editing

See Editor Architecture for technical details.

rinch-components

UI component library (~55 components):

  • Input components: TextInput, Checkbox, Switch, Select, Slider
  • Display components: Button, Badge, Alert, Card, Notification
  • Layout components: Stack, Group, Grid, Container, Accordion
  • Navigation components: Tabs, NavLink, Breadcrumbs, Pagination
  • Overlay components: Modal, Drawer, Tooltip, Popover, Menu
  • Typography components: Text, Title, Code, Blockquote
  • Data display: Table, List, Timeline, Avatar

rinch-editable

Text editing abstractions:

  • EditableDocument - Trait for text editing operations
  • EditCommand - Enum of editing commands (insert, delete, etc.)
  • EditableState - Cursor position, selection state

rinch-clipboard

Cross-platform clipboard abstraction:

  • arboard for native platforms
  • web-sys for WASM
  • Unified API: copy_text(), paste_text(), has_text()

rinch-tabler-icons

5000+ Tabler Icons with type-safe enum:

  • Build-time download - Icons fetched from Tabler CDN during cargo build
  • Type-safe - TablerIcon enum instead of strings
  • Two styles - Outline and Filled variants
  • Tree-shaking friendly - Rust dead code elimination removes unused icons
  • render_tabler_icon() - Helper function for rendering icons

rinch-debug

Debug IPC server for development tools:

  • TCP listener - Auto-starts on random localhost port
  • Discovery files - Writes ~/.rinch/debug/{pid}.json
  • Protocol - Length-prefixed JSON over TCP
  • Commands - DOM inspection, screenshot, input simulation

rinch-mcp-server

MCP server for Claude integration:

  • Standalone binary - Connects to running rinch apps via TCP
  • Discovery - Scans ~/.rinch/debug/*.json to find apps
  • MCP tools - screenshot, dom_tree, click, type_text, query_selector, etc.
  • Auto-connect - Automatically connects when only one app is running

Data Flow

         User Code (app function)
                   │
                   ▼
    ┌──────────────────────────────┐
    │         rsx! macro           │  Compile time
    │   (generates DOM construction│
    │    code with Effects)        │
    └──────────────────────────────┘
                   │
                   ▼
    ┌──────────────────────────────┐
    │       RenderScope            │  Initial render
    │   (builds DOM tree once)     │  (runs app() once)
    └──────────────────────────────┘
                   │
         ┌────────┴────────┐
         ▼                 ▼
┌─────────────┐    ┌─────────────┐
│  NodeHandle │    │   Effects   │
│   (DOM refs)│    │ (reactive)  │
└─────────────┘    └─────────────┘
         │                 │
         │    Signal.set() │
         │        ↓        │
         │    Effect runs  │
         │        ↓        │
         └────────┬────────┘
                  │
                  ▼
    ┌──────────────────────────────┐
    │     RinchDocument            │  DOM mutation
    │   (surgical updates via      │
    │    NodeHandle methods)       │
    └──────────────────────────────┘
                  │
                  ▼
    ┌──────────────────────────────┐
    │   Taffy Layout Engine        │  Compute layout
    │   (flexbox/grid)             │
    └──────────────────────────────┘
                  │
                  ▼
    ┌──────────────────────────────┐
    │   Parley Text Shaping        │  Shape text
    │   (line breaking, glyphs)    │
    └──────────────────────────────┘
                  │
                  ▼
    ┌──────────────────────────────┐
    │    Vello / tiny-skia         │  GPU or software rendering
    │       (re-paint)             │
    └──────────────────────────────┘
                  │
                  ▼
              Display

Reactive Update Flow

When a signal changes:

  1. Signal.set() - User code updates a signal
  2. Dependency notification - Signal notifies subscribed Effects
  3. Effect execution - Each Effect re-runs its closure
  4. DOM mutation - Effect uses NodeHandle to update specific DOM nodes
  5. Mark dirty - Changed nodes are marked for re-layout
  6. Re-paint - Vello re-renders the affected region

This is much more efficient than regenerating HTML and replacing the entire document.

External Dependencies

CratePurpose
TaffyFlexbox and grid layout engine
ParleyText shaping, line breaking, bidirectional text
StyloCSS parsing and computed style resolution
VelloGPU-accelerated 2D rendering (GPU mode)
tiny-skiaCPU-based 2D rendering (software mode)
softbufferSoftware window presentation (software mode)
wgpuCross-platform GPU abstraction (WebGPU API)
winitCross-platform windowing and input
mudaNative menu support (macOS/Windows/Linux)
arboardCross-platform clipboard access
AutomergeCRDT for collaborative editing (rinch-editor)

Design Principles

  1. Fine-grained updates - Only update what changed, never full re-render
  2. Declarative UI - RSX syntax describes UI structure
  3. Reactive by default - Signals and Effects for state management
  4. Web standards - HTML/CSS for layout via Taffy and Stylo
  5. Platform abstraction - Write once, run on desktop and web
  6. Native integration - Native menus, file dialogs, clipboard, system tray
  7. Developer experience - MCP integration for visual testing and debugging

Fine-Grained Reactive Rendering

Rinch uses fine-grained reactive rendering to achieve efficient UI updates. Instead of regenerating the entire DOM on every state change, Rinch surgically updates only the specific nodes that depend on changed signals.

Core Concepts

The Problem with Full Re-rendering

Traditional approaches regenerate HTML on every state change:

Signal.set() → re-run app() → generate full HTML → replace entire Document

This is inefficient because:

  • Small changes cause full tree reconstruction
  • Scroll positions are lost
  • Focus state is lost
  • Animation state is reset
  • Layout is recalculated for the entire document

The Fine-Grained Solution

Rinch’s approach runs app() once and uses Effects for updates:

Signal.set() → Effect runs → NodeHandle.set_text() → Minimal re-layout

Benefits:

  • Only changed nodes are updated
  • Scroll and focus preserved
  • Sub-millisecond updates for text changes
  • No HTML parsing overhead

Reactive Primitives

Signal

A reactive state container that notifies subscribers when its value changes.

#![allow(unused)]
fn main() {
let count = Signal::new(0);

// Read (subscribes the current Effect)
let value = count.get();

// Write (notifies all subscribers)
count.set(5);

// Update (read-modify-write)
count.update(|n| *n += 1);
}

Note: Signal<T> implements Copy, so you can use the same signal in multiple closures without .clone().

Tracking: When get() is called inside an Effect, that Effect becomes a subscriber. When set() is called, all subscribers are notified and re-run.

Effect

A side-effect that re-runs when its dependencies change.

#![allow(unused)]
fn main() {
let count = Signal::new(0);
let node = __scope.create_element("span");

// This Effect will re-run whenever count changes
// Signal and NodeHandle are both Copy — no .clone() needed
__scope.create_effect(move || {
    node.set_text(&count.get().to_string());
});
}

Lifecycle:

  1. Creation - Effect runs immediately, tracking any signals accessed
  2. Dependency change - When a tracked signal changes, Effect is queued
  3. Re-execution - Effect runs again, updating its output
  4. Cleanup - When the scope is disposed, Effects are cleaned up

Memo

A cached computed value that only recomputes when dependencies change.

#![allow(unused)]
fn main() {
let items = Signal::new(vec![1, 2, 3, 4, 5]);

// Only recomputes when items changes
// Signal is Copy — no .clone() needed
let sum = Memo::new(move || items.get().iter().sum::<i32>());

// Reading sum.get() returns cached value if items hasn't changed
}

Laziness: Memos are lazy - they only compute when first accessed and recompute only when dependencies change AND the value is accessed again.

Dependency Tracking

Rinch uses automatic dependency tracking. You don’t need to declare dependencies - they are discovered at runtime.

How It Works

  1. When an Effect runs, it sets itself as the “current observer”
  2. Any signal.get() call checks for a current observer
  3. If present, the signal adds the observer to its subscriber list
  4. When the signal changes, it notifies all subscribers
#![allow(unused)]
fn main() {
// Automatic tracking example
let a = Signal::new(1);
let b = Signal::new(2);

__scope.create_effect(move || {
    // Both a and b are automatically tracked
    let sum = a.get() + b.get();
    println!("Sum: {}", sum);
});

a.set(10);  // Effect re-runs, prints "Sum: 12"
b.set(20);  // Effect re-runs, prints "Sum: 30"
}

Conditional Tracking

Dependencies are tracked dynamically. If a branch isn’t taken, those signals aren’t tracked:

#![allow(unused)]
fn main() {
let show_a = Signal::new(true);
let a = Signal::new("A");
let b = Signal::new("B");

__scope.create_effect(move || {
    if show_a.get() {
        println!("{}", a.get());  // Only tracked when show_a is true
    } else {
        println!("{}", b.get());  // Only tracked when show_a is false
    }
});
}

DOM Integration

NodeHandle

A stable reference to a DOM node that enables surgical updates:

#![allow(unused)]
fn main() {
let node = __scope.create_element("div");

// Text content
node.set_text("Hello");

// Attributes
node.set_attribute("class", "active");
node.remove_attribute("disabled");

// Styles
node.set_style("color", "red");
node.set_style("display", "none");

// Classes
node.add_class("highlighted");
node.remove_class("dimmed");

// Tree manipulation
node.append_child(&child_node);
node.insert_before(&new_node, &reference_node);
node.remove();
}

Reactive DOM Updates

The rsx! macro creates Effects for reactive expressions:

#![allow(unused)]
fn main() {
rsx! {
    div {
        // Static text - rendered once
        "Hello, "

        // Reactive expression - creates an Effect
        {|| name.get()}

        // Reactive style - creates an Effect
        style: {|| format!("color: {}", color.get())},
    }
}
}

Under the hood, this generates:

#![allow(unused)]
fn main() {
let div = __scope.create_element("div");

// Static text
let text1 = __scope.create_text("Hello, ");
div.append_child(&text1);

// Reactive text - creates Effect
let text2 = __scope.create_text(&name.get().to_string());
div.append_child(&text2);
// Signal and NodeHandle are Copy — no .clone() needed
__scope.create_effect(move || {
    text2.set_text(&name.get().to_string());
});

// Reactive style - creates Effect
__scope.create_effect(move || {
    div.set_style("color", &color.get());
});
}

Batch Updates

Multiple signal changes can be batched to avoid redundant Effect executions:

#![allow(unused)]
fn main() {
// Without batching - Effect runs 3 times
a.set(1);
b.set(2);
c.set(3);

// With batching - Effect runs once
batch(|| {
    a.set(1);
    b.set(2);
    c.set(3);
});
}

Conditional Rendering (Show)

The show_dom() function handles conditional rendering with fine-grained updates:

#![allow(unused)]
fn main() {
show_dom(
    __scope,
    &parent,
    move || condition.get(),                          // Condition closure
    |scope| {                                         // Then branch
        let div = scope.create_element("div");
        div.set_text("Visible");
        div
    },
    Some(|scope| {                                    // Else branch (optional)
        let span = scope.create_element("span");
        span.set_text("Hidden");
        span
    }),
)
}

In RSX, use the Show component instead:

#![allow(unused)]
fn main() {
rsx! {
    Show {
        when: {move || condition.get()},
        fallback: |__scope: &mut RenderScope| rsx! { span { "Hidden" } },
        div { "Visible" }
    }
}
}

When the condition changes:

  1. The Effect runs
  2. The old content’s scope is disposed (cleaning up nested effects)
  3. Old DOM content nodes are removed
  4. A new child scope is created and new content is rendered after the marker

List Rendering (For)

The for_each_dom() function handles keyed list rendering using ForItem:

#![allow(unused)]
fn main() {
for_each_dom(
    __scope,
    &parent,
    move || items.get().into_iter().map(|item| {    // Items closure returning Vec<ForItem>
        ForItem::new(item.id.clone(), item)
    }).collect(),
    |item, scope| {                                  // Item renderer
        let data = item.downcast::<Item>().unwrap();
        let li = scope.create_element("li");
        li.set_text(&data.name);
        li
    },
)
}

In RSX, use the For component instead:

#![allow(unused)]
fn main() {
rsx! {
    For {
        each: {move || items.get().into_iter().map(|item| {
            ForItem::new(item.id.clone(), item)
        }).collect()},
        |item: &ForItem| {
            let data = item.downcast::<Item>().unwrap();
            rsx! { li { {data.name.clone()} } }
        }
    }
}
}

When items change:

  1. Diff algorithm (LIS-based) compares old and new key lists
  2. Items with unchanged keys are preserved (not re-rendered)
  3. New items are inserted at correct positions
  4. Removed items have their scopes disposed and nodes cleaned up
  5. Moved items are repositioned in the DOM

Comparison with Other Approaches

ApproachUpdate GranularityPerformanceComplexity
Full re-renderEntire DOMO(n)Simple
Virtual DOMSubtree patchesO(log n)Medium
Fine-grainedSingle nodesO(1)Complex

Rinch’s fine-grained approach provides the best performance for reactive updates, at the cost of more sophisticated compilation and runtime machinery.

Performance Characteristics

  • Text updates: < 1ms (single node mutation)
  • Style changes: < 1ms (single node mutation)
  • List item add/remove: ~5ms (DOM operations + layout)
  • Full conditional swap: ~10ms (subtree rebuild)

These are typical measurements; actual performance depends on document complexity and hardware.

RenderScope and NodeHandle API

This document specifies the DOM abstraction layer that enables fine-grained reactive rendering.

Overview

The DOM abstraction consists of three key types:

TypePurpose
RenderScopeContext for building DOM trees with effect tracking
NodeHandleStable reference to a DOM node for surgical updates
DomDocumentTrait abstracting DOM mutation operations

RenderScope

RenderScope is the context passed to component functions. It provides methods for creating DOM nodes and Effects.

Creating Nodes

The recommended approach uses the #[component] macro, which injects __scope automatically:

#![allow(unused)]
fn main() {
#[component]
fn my_component() -> NodeHandle {
    rsx! {
        div {
            "Hello, world!"
        }
    }
}
}

For manual DOM construction, use __scope directly:

#![allow(unused)]
fn main() {
fn my_component(__scope: &mut RenderScope) -> NodeHandle {
    // Create an element node
    let div = __scope.create_element("div");

    // Create a text node
    let text = __scope.create_text("Hello, world!");

    // Create a comment node (useful for anchors)
    let comment = __scope.create_comment("placeholder");

    // Build the tree
    div.append_child(&text);

    div
}
}

Creating Effects

#![allow(unused)]
fn main() {
fn counter(__scope: &mut RenderScope) -> NodeHandle {
    let count = Signal::new(0);
    let span = __scope.create_element("span");

    // Create an Effect that updates the span when count changes
    // Signal and NodeHandle are Copy — no .clone() needed
    __scope.create_effect(move || {
        span.set_text(&count.get().to_string());
    });

    span
}
}

Child Scopes

Child scopes inherit the document but have their own Effect tracking. The child_scope method takes a parent NodeHandle reference and returns a mutable borrow:

#![allow(unused)]
fn main() {
fn parent(__scope: &mut RenderScope) -> NodeHandle {
    let container = __scope.create_element("div");

    // Create a child scope for a nested component
    let child_scope = __scope.child_scope(&container);
    let child_content = child_component(child_scope);
    container.append_child(&child_content);

    container
}
}

When a child scope is dropped, all its Effects are cleaned up.

Event Handling

Rinch uses a register_handler + data-rid pattern for event dispatch. There is no add_event_listener on NodeHandle. Instead, handlers are registered on the RenderScope and linked to elements via a data-rid attribute:

#![allow(unused)]
fn main() {
fn my_button(__scope: &mut RenderScope) -> NodeHandle {
    let button = __scope.create_element("button");
    button.set_text("Click me");

    // Register a handler and link it to the element
    let handler_id = __scope.register_handler(move || {
        println!("Button clicked!");
    });
    button.set_attribute("data-rid", &handler_id.to_string());

    button
}
}

The rsx! macro handles this automatically with onclick::

#![allow(unused)]
fn main() {
#[component]
fn my_button() -> NodeHandle {
    rsx! {
        button { onclick: move || println!("Clicked!"),
            "Click me"
        }
    }
}
}

The runtime uses event delegation (a single document-level listener) that dispatches to the correct handler by looking up the data-rid attribute on the clicked element.

RenderScope API Reference

MethodDescription
create_element(tag: &str) -> NodeHandleCreate an element node (div, span, etc.)
create_text(content: &str) -> NodeHandleCreate a text node
create_comment(content: &str) -> NodeHandleCreate a comment node
create_effect(f: impl FnMut() + 'static)Create a reactive Effect
child_scope(&mut self, parent: &NodeHandle) -> &mut RenderScopeCreate a child scope rooted at a parent node
register_handler(callback: impl Fn() + 'static) -> EventHandlerIdRegister an event handler, returns ID for data-rid
register_input_handler(callback: impl Fn(String) + 'static) -> EventHandlerIdRegister an input handler for text input events
parent() -> NodeHandleGet the parent node for this scope
doc_weak() -> Weak<RefCell<dyn DomDocument>>Get a weak reference to the underlying document
body_handle() -> NodeHandleGet the body element as a NodeHandle
dispose(self)Dispose this scope, cleaning up all Effects

NodeHandle

NodeHandle is a stable reference to a DOM node. It remains valid even as the document changes around it. Internally it holds a NodeId and a Weak<RefCell<dyn DomDocument>>.

Text Content

#![allow(unused)]
fn main() {
let text_node = __scope.create_text("initial");

// Update text content
text_node.set_text("updated");
}

Attributes

#![allow(unused)]
fn main() {
let button = __scope.create_element("button");

// Set attribute
button.set_attribute("disabled", "true");
button.set_attribute("aria-label", "Submit form");

// Get attribute
let label = button.get_attribute("aria-label"); // Some("Submit form")

// Remove attribute
button.remove_attribute("disabled");
}

Styles

#![allow(unused)]
fn main() {
let div = __scope.create_element("div");

// Set individual styles
div.set_style("color", "blue");
div.set_style("font-size", "16px");
div.set_style("display", "flex");

// Remove a style
div.set_style("color", "");  // Empty string removes
}

Classes

#![allow(unused)]
fn main() {
let element = __scope.create_element("div");

// Set the class attribute directly
element.set_class("active highlighted");

// Add/remove individual classes
element.add_class("active");
element.add_class("highlighted");
element.remove_class("active");

// Toggle based on condition
element.toggle_class("selected");

// Or conditionally:
if is_selected {
    element.add_class("selected");
} else {
    element.remove_class("selected");
}
}

Tree Manipulation

#![allow(unused)]
fn main() {
let parent = __scope.create_element("ul");
let item1 = __scope.create_element("li");
let item2 = __scope.create_element("li");
let item3 = __scope.create_element("li");

// Append children
parent.append_child(&item1);
parent.append_child(&item3);

// Insert before a reference node
parent.insert_before(&item2, &item3);  // item1, item2, item3

// Remove a node
item2.remove();  // item1, item3

// Replace a node
let new_item = __scope.create_element("li");
item1.replace_with(&new_item);
}

Focus

#![allow(unused)]
fn main() {
let input = __scope.create_element("input");
input.focus();  // Give focus to this element
}

NodeHandle API Reference

MethodDescription
set_text(content: &str)Set text content (for text nodes)
set_attribute(name: &str, value: &str)Set an attribute
get_attribute(name: &str) -> Option<String>Get an attribute value
remove_attribute(name: &str)Remove an attribute
set_style(property: &str, value: &str)Set a CSS style property
set_class(class: &str)Set the class attribute
add_class(name: &str)Add a CSS class
remove_class(name: &str)Remove a CSS class
toggle_class(name: &str)Toggle a CSS class
append_child(child: &NodeHandle)Append a child node
insert_before(node: &NodeHandle, reference: &NodeHandle)Insert before reference
remove()Remove this node from its parent
replace_with(new_node: &NodeHandle)Replace this node with another
focus()Give focus to this element
children() -> Vec<NodeHandle>Get child nodes as NodeHandles
is_valid() -> boolCheck if this handle still points to a valid node
node_id() -> NodeIdGet the internal node ID
clone() -> NodeHandleClone the handle (same underlying node)

DomDocument Trait

DomDocument is the trait that abstracts DOM operations. The primary desktop implementation is RinchDocument which uses Taffy + Parley + Vello. The web implementation is WebDocument which uses browser-native DOM via web_sys.

#![allow(unused)]
fn main() {
pub trait DomDocument {
    /// Create an element node
    fn create_element(&mut self, tag: &str) -> NodeId;

    /// Create a text node
    fn create_text(&mut self, content: &str) -> NodeId;

    /// Create a comment node
    fn create_comment(&mut self, content: &str) -> NodeId;

    /// Set text content of a node
    fn set_text_content(&mut self, node: NodeId, content: &str);

    /// Set an attribute
    fn set_attribute(&mut self, node: NodeId, name: &str, value: &str);

    /// Remove an attribute
    fn remove_attribute(&mut self, node: NodeId, name: &str);

    /// Append a child to a parent
    fn append_child(&mut self, parent: NodeId, child: NodeId);

    /// Insert a node before a reference node
    fn insert_before(&mut self, parent: NodeId, node: NodeId, reference: NodeId);

    /// Remove a node from its parent
    fn remove_child(&mut self, parent: NodeId, child: NodeId);

    /// Get children of a node
    fn get_children(&self, node: NodeId) -> Vec<NodeId>;

    /// Get the body element
    fn body(&self) -> NodeId;

    /// Mark a node as needing re-layout
    fn mark_dirty(&mut self, node: NodeId);
}
}

RinchDocument

RinchDocument is the desktop implementation of DomDocument that uses Taffy for layout, Parley for text, and Vello for rendering.

Key Features

  • Direct DOM manipulation - Efficient node creation and mutation
  • Automatic dirty marking - Calls mark_ancestors_dirty() after mutations
  • Event handler storage - Stores handlers as data-rid attributes for dispatch

Usage

#![allow(unused)]
fn main() {
use rinch_dom::RinchDocument;

// Create document
let doc = Rc::new(RefCell::new(RinchDocument::new()));

// Create RenderScope from shared document
let mut scope = RenderScope::new(Rc::downgrade(&doc) as _, parent_id);

// Build DOM
let root = my_app(&mut scope);
}

Thread Safety

RinchDocument is wrapped in Rc<RefCell<>> for interior mutability.

Effects capture clones of the shared document and can mutate the DOM when they run.

RenderScope itself holds a Weak<RefCell<dyn DomDocument>> to avoid preventing cleanup.

Integration with Reactive System

The RenderScope and NodeHandle APIs integrate with the reactive system:

  1. Initial render - Component function receives RenderScope (via __scope or #[component] macro), builds DOM tree
  2. Effect creation - __scope.create_effect() registers reactive computations
  3. NodeHandle capture - Effects capture NodeHandle clones for later updates
  4. Signal changes - Effects re-run and use NodeHandle methods to update DOM
  5. Cleanup - When scope is dropped, Effects are disposed

This architecture ensures that:

  • Components run once (no re-render overhead)
  • Updates are surgical (only affected nodes change)
  • Cleanup is automatic (scope disposal cleans up Effects)
  • Memory is efficient (NodeHandle is a lightweight ID + weak reference wrapper)

Reactive System Architecture

Rinch uses a fine-grained reactivity model inspired by Solid.js and Leptos. This document describes the architecture and design decisions.

Core Concepts

Signals

A Signal is a reactive container that holds a value and notifies subscribers when it changes.

#![allow(unused)]
fn main() {
let count = Signal::new(0);

// Read the value
let value = count.get();

// Update the value (triggers subscribers)
count.set(5);

// Update based on current value
count.update(|n| *n += 1);
}

Key properties:

  • Reading a signal inside an effect automatically subscribes to it
  • Setting a signal schedules dependent effects to re-run
  • Signals are Clone and can be shared across closures

Effects

An Effect is a side-effect that re-runs when its dependencies change.

#![allow(unused)]
fn main() {
let count = Signal::new(0);

Effect::new(move || {
    println!("Count is now: {}", count.get());
});

count.set(1); // Prints: "Count is now: 1"
count.set(2); // Prints: "Count is now: 2"
}

Key properties:

  • Dependencies are tracked automatically (no dependency arrays)
  • Effects run immediately when created, then re-run when dependencies change
  • Effects are cleaned up when their scope is disposed

Memos

A Memo is a cached computed value that only recomputes when dependencies change.

#![allow(unused)]
fn main() {
let count = Signal::new(2);
let doubled = Memo::new(move || count.get() * 2);

doubled.get(); // Returns 4
count.set(3);
doubled.get(); // Returns 6 (recomputed)
doubled.get(); // Returns 6 (cached)
}

Key properties:

  • Lazily evaluated (only computes when read)
  • Caches the result until dependencies change
  • Can be read inside effects (creates a subscription)

Dependency Tracking

Rinch uses automatic dependency tracking at runtime:

  1. When an effect runs, it registers itself as the “current observer”
  2. When a signal is read, it checks for a current observer and subscribes it
  3. When a signal changes, it notifies all subscribers to re-run
┌─────────────┐     read      ┌─────────────┐
│   Effect    │ ───────────── │   Signal    │
│             │               │             │
│  observer   │ ◄──subscribe──│ subscribers │
└─────────────┘               └─────────────┘
                                    │
                                    │ set()
                                    ▼
                              notify observers

Runtime Architecture

┌────────────────────────────────────────────────────┐
│                    Runtime                          │
├────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐               │
│  │ Observer     │  │ Pending      │               │
│  │ Stack        │  │ Effects      │               │
│  └──────────────┘  └──────────────┘               │
│                                                    │
│  ┌──────────────────────────────────────────────┐ │
│  │              Signal Storage                   │ │
│  │  ┌────────┐ ┌────────┐ ┌────────┐           │ │
│  │  │Signal 1│ │Signal 2│ │Signal 3│  ...      │ │
│  │  └────────┘ └────────┘ └────────┘           │ │
│  └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘

Observer Stack

The runtime maintains a stack of “current observers”:

  • When an effect starts, it pushes itself onto the stack
  • When a signal is read, it subscribes the top of the stack
  • When an effect ends, it pops itself

This allows nested effects to work correctly.

Batching

Multiple signal updates can be batched to avoid redundant effect runs:

#![allow(unused)]
fn main() {
batch(|| {
    count.set(1);
    name.set("Alice");
    // Effects only run once, after the batch
});
}

Scheduling

Effects are scheduled to run after the current synchronous code completes:

  1. Signal is set → effect is marked as “dirty”
  2. Dirty effects are queued for execution
  3. After current execution, queued effects run
  4. This prevents infinite loops and ensures consistent state

Memory Management

Scopes

A Scope manages the lifetime of reactive primitives. In practice, RenderScope serves as the scope for component rendering:

#![allow(unused)]
fn main() {
// RenderScope is the scope used during rendering.
// Effects created via __scope.create_effect() are tracked
// and disposed when the scope is dropped.

fn my_component(__scope: &mut RenderScope) -> NodeHandle {
    let signal = Signal::new(0);
    __scope.create_effect(|| { /* tracked by this scope */ });
    // signal and effect belong to this scope
    rsx! { div { } }
}
// When __scope is disposed, all its effects are cleaned up
}

Note: RenderScope is what application code sees — it tracks effects and child scopes for automatic cleanup. Underneath, each RenderScope owns a reactive Scope, and Scope::run(f) makes that scope the ambient owner for f: signals, memos, effects and event handlers created inside are attributed to it. Attribution is currently recorded but not acted on — see issue #141, which turns these records into actual reclamation.

Two independent stacks are in play, and they are easy to confuse: untracked suspends the observer stack (who subscribes to a read), while unowned suspends the owner stack (who owns an allocation). With no ambient owner, a resource has app lifetime — which is why signals created in main() or in startup code keep working untouched.

Ownership

  • Signals are reference-counted (Rc<RefCell<T>>)
  • Effects hold strong references to their closures
  • Disposing a scope drops all its primitives

Integration with UI

The reactive system integrates with the rendering pipeline:

  1. Component functions run inside a RenderScope
  2. RSX expressions use closure syntax for reactive reads: {|| count.get().to_string()}
  3. When signals change, Effects re-run and surgically update affected DOM nodes
  4. Fine-grained updates - only the specific nodes bound to changed signals are updated
#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
    let count = Signal::new(0);

    rsx! {
        div {
            // Reactive text - closure syntax {|| ...} creates an Effect
            "Count: " {|| count.get().to_string()}

            button { onclick: move || count.update(|n| *n += 1),
                "Increment"
            }
        }
    }
}
}

Note: The closure syntax {|| expr} is required for reactive updates — without it, values are captured once at initial render and never update.

Thread Safety

The current implementation uses Rc<RefCell<T>> for single-threaded use:

  • Thread-local runtime by default
  • All reactive primitives (Signal, Effect, Memo) are !Send and !Sync
  • This is intentional: GUI frameworks are inherently single-threaded (main thread)
  • The observer stack and effect scheduling are thread-local

Comparison with Other Systems

FeatureRinchReactSolid.jsLeptos
ReactivityFine-grainedCoarse (VDOM)Fine-grainedFine-grained
TrackingAutomaticManual (deps array)AutomaticAutomatic
SchedulingBatchedBatchedSynchronousBatched
MemoryScopedGCScopedScoped

Rendering Pipeline

Rinch uses a multi-stage rendering pipeline that transforms component code into pixels on the desktop backend. Two rendering backends are available: GPU (Vello/wgpu) and software (tiny-skia/softbuffer). The web backend uses browser-native DOM instead (see note at the end).

Pipeline Stages (Desktop)

┌───────────────────────────────────────────────────────────────┐
│                   1. Component Input                            │
│  #[component] functions + rsx! macro generate DOM construction │
│  code via __scope.create_element(), create_text(),             │
│  create_effect()                                               │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                   2. DOM Construction                           │
│  DomDocument creates nodes programmatically via RenderScope   │
│  (RinchDocument uses Taffy + Parley on desktop)               │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                   3. Style Resolution                           │
│  Stylo (Firefox's CSS engine) computes styles for each node   │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                   4. Layout                                     │
│  Taffy computes the position and size of each element         │
└───────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌───────────────────────────────────────────────────────────────┐
│                   5. Painting (via Painter trait)                │
│  paint_document() walks the DOM tree and emits drawing        │
│  commands through the abstract Painter interface               │
└───────────────────────────────────────────────────────────────┘
                              │
                    ┌─────────┴─────────┐
                    ▼                   ▼
┌──────────────────────────┐  ┌──────────────────────────┐
│  6a. GPU (Vello)         │  │  6b. Software (tiny-skia) │
│  Scene graph → wgpu →    │  │  Rasterize to RGBA pixmap │
│  GPU compositing         │  │  → softbuffer → display   │
└──────────────────────────┘  └──────────────────────────┘
                    │                   │
                    └─────────┬─────────┘
                              ▼
                          Display

Input Stage

User code defines components using the #[component] macro and rsx! macro:

#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
    let count = Signal::new(0);
    rsx! {
        div {
            p { "Count: " {|| count.get().to_string()} }
            button { onclick: move || count.update(|n| *n += 1), "+" }
        }
    }
}
}

The #[component] macro injects a __scope: &mut RenderScope parameter. The rsx! macro generates calls to __scope.create_element(), __scope.create_text(), and __scope.create_effect() to build the DOM tree programmatically. No HTML strings are generated or parsed at runtime.

Key Technologies

rinch-dom

The custom DOM and layout engine built specifically for Rinch:

  • rinch-dom - DOM implementation with Taffy layout, Parley text shaping, and a Painter trait for backend-agnostic rendering
  • VelloPainter - GPU backend: records drawing commands into a Vello scene graph
  • TinySkiaPainter - Software backend: rasterizes directly to an RGBA pixel buffer

Stylo

Mozilla’s CSS engine (from Firefox) provides:

  • Full CSS specification support
  • Efficient style computation
  • Media query handling
  • CSS custom properties

Taffy

A flexbox/grid layout engine that computes:

  • Element positions (x, y)
  • Element sizes (width, height)
  • Flexbox alignment and distribution
  • CSS Grid support

Painter Trait

All rendering goes through the Painter trait, which abstracts over both backends:

#![allow(unused)]
fn main() {
pub trait Painter {
    fn fill(&mut self, fill: Fill, transform: Affine, brush: &Brush, shape: &PaintShape);
    fn stroke(&mut self, stroke: &Stroke, transform: Affine, brush: &Brush, shape: &PaintShape);
    fn draw_glyphs(&mut self, font: &FontData, font_size: f32, ...);
    fn draw_image(&mut self, image: &PaintImage, transform: Affine);
    fn push_clip(&mut self, fill: Fill, transform: Affine, shape: &PaintShape);
    fn push_layer(&mut self, blend: BlendMode, opacity: f32, ...);
    fn pop_layer(&mut self);
    // ...
}
}

Application code never interacts with the Painter directly — the backend is selected at compile time via Cargo features.

Vello (GPU Backend)

A GPU-accelerated 2D graphics library (enabled with features = ["gpu"]):

  • Scene graph-based rendering
  • Efficient batching
  • High-quality anti-aliasing
  • Path rendering (beziers, fills, strokes)
  • Text rendering with proper shaping
  • Requires wgpu (Vulkan, Metal, DX12, or WebGPU)

tiny-skia (Software Backend)

A CPU-based rasterizer (the default when gpu is not enabled):

  • Direct pixel rendering to an RGBA buffer
  • Presented via softbuffer (no GPU required)
  • Dirty region caching — only changed areas are repainted
  • Subtree pruning — nodes outside the dirty region are skipped
  • Works in headless environments, CI, containers, SSH sessions

Rendering Backends

Choosing a Backend

Set it in your Cargo.toml:

# GPU mode (recommended for most apps):
rinch = { workspace = true, features = ["desktop", "gpu"] }

# Software mode (default — no GPU required):
rinch = { workspace = true, features = ["desktop"] }

GPU Rendering Flow

#![allow(unused)]
fn main() {
// Simplified GPU rendering flow
fn paint_gpu(&mut self) {
    let scene = self.app.build_scene(scale, size);
    self.renderer.render_to_surface(&scene, &params, &surface);
}
}

Software Rendering Flow

#![allow(unused)]
fn main() {
// Simplified software rendering flow
fn paint_software(&mut self) {
    let (pixels, w, h) = self.app.build_pixels(scale, size, &layers);
    // pixels are blitted to the window via softbuffer
}
}

The software renderer includes dirty region caching: when only a small part of the UI changes (e.g., cursor blink, hover feedback), only the affected rectangular region is cleared and repainted. Nodes outside the dirty region are skipped entirely during the paint traversal.

Incremental Updates

When content changes, the pipeline can skip unchanged stages:

  1. Style cache - Styles are cached per element selector
  2. Layout cache - Layout is only recomputed for affected subtrees
  3. Scene diffing - Only changed primitives are re-rendered

Performance Characteristics

StageComplexityCaching
DOM BuildO(n)Incremental (surgical updates)
Style ResolveO(n x rules)Selector cache
LayoutO(n)Subtree cache
PaintO(visible)Dirty region caching (software)
GPU RenderO(primitives)GPU buffers (GPU mode)

Web Backend

The pipeline above is desktop-only. The web backend (ui-zoo-web) takes a completely different path:

#[component] + rsx! → DOM construction code → WebDocument (web_sys) → Browser-native DOM

On the web, WebDocument implements DomDocument using web_sys to create real browser DOM elements. The browser handles style resolution, layout, painting, and compositing natively. No Taffy, Parley, Stylo, Vello, or wgpu are needed for the web backend, resulting in a much smaller WASM binary.

Optimizations

Current and planned improvements to the rendering pipeline:

  • Dirty region caching (software) - Only repaint the rectangular area covering changed nodes
  • Subtree pruning (software) - Skip paint traversal for nodes outside the dirty region
  • Sensitivity flags - Hover/active/focus only trigger repaints for nodes with matching CSS selectors
  • Batched redraws - Multiple state changes are batched into a single repaint via AboutToWait
  • Layer compositing - GPU layers for transformed content (planned)
  • Text caching - Glyph atlas for repeated text (planned)
  • Viewport culling - Skip off-screen content (planned)

Rich-Text Editor Architecture

The rinch-editor is a comprehensive rich-text editor built for the Rinch GUI framework with full collaborative editing support through Automerge CRDT.

Design Overview

The editor uses a Hidden Textarea + Custom Virtual DOM approach to balance:

  • Full IME and clipboard support
  • Native browser input handling (without being in a browser)
  • Surgical DOM updates (only changed nodes update)
  • Collaboration-friendly undo/redo

Key Principle

Hidden Textarea → Key Events → Editor Commands → Document Model → Virtual DOM Render
                  (Clipboard)      (Dispatch)      (Automerge)      (NodeHandle)

The document model (Automerge CRDT) is the single source of truth. All mutations go through commands, which update the document, which then updates the DOM via Effects.

Core Components

Editor Instance

#![allow(unused)]
fn main() {
pub struct Editor {
    pub doc: EditorDocument,           // Automerge CRDT document
    pub schema: Schema,                // Validation rules
    pub selection: SelectionState,     // Cursor/selection
    pub history: History,              // Local undo/redo
    pub commands: CommandDispatcher,   // Command execution
    pub extensions: ExtensionRegistry, // Loaded plugins
    pub input_rules: InputRuleSet,     // Auto-transforms
    pub shortcuts: ShortcutRegistry,   // Keyboard bindings
    pub events: EventDispatcher,       // Event routing
    pub config: EditorConfig,          // Settings
}
}

Document Model

The EditorDocument wraps an Automerge document:

#![allow(unused)]
fn main() {
pub struct EditorDocument {
    // Contains:
    // - Block nodes (paragraph, heading, list, code_block, etc.)
    // - Inline content (text, marks)
    // - Mark data (bold, italic, link, etc.)
    //
    // Operations:
    // - insert_text(pos, text)
    // - delete_range(range)
    // - add_mark(range, mark, attrs)
    // - remove_mark(range, mark)
    // - split_block(pos)
}
}

Key properties:

  • CRDT-backed: Changes can be merged with remote edits
  • Immutable snapshots: Document state at any point can be accessed
  • Collaborative: Multiple users can edit simultaneously
  • Local undo: Operations track inverses for reversal

Schema System

The schema defines valid document structure:

#![allow(unused)]
fn main() {
pub struct Schema {
    pub nodes: HashMap<String, NodeSpec>,
    pub marks: HashMap<String, MarkSpec>,
    pub top_node: String,  // Usually "doc"
}

pub struct NodeSpec {
    pub name: String,
    pub content: Option<String>,      // "block+", "inline*", etc.
    pub group: Option<String>,        // block, inline grouping
    pub attrs: HashMap<String, AttrSpec>,
    pub inline: bool,
    pub atom: bool,                   // No content (leaf node)
    pub isolating: bool,              // Boundary for operations
    pub marks: MarkSet,               // Which marks allowed
}

pub struct MarkSpec {
    pub name: String,
    pub attrs: HashMap<String, AttrSpec>,
    pub inclusive: bool,              // Extend to new typing
    pub excludes: Option<String>,     // Conflicting marks
}
}

Extension System

Extensions are plugins that contribute to the editor:

#![allow(unused)]
fn main() {
pub trait Extension: Debug {
    fn name(&self) -> &str;
    fn priority(&self) -> i32 { 100 }
    fn nodes(&self) -> Vec<NodeSpec> { vec![] }
    fn marks(&self) -> Vec<MarkSpec> { vec![] }
    fn commands(&self) -> Vec<CommandRegistration> { vec![] }
    fn keyboard_shortcuts(&self) -> Vec<(KeyboardShortcut, String)> { vec![] }
    fn input_rules(&self) -> Vec<InputRule> { vec![] }
    fn on_init(&self, editor: &mut Editor) -> Result<(), EditorError> { Ok(()) }
}
}

Extension lifecycle:

  1. Extensions register nodes, marks, commands
  2. Schema is built from all extensions
  3. Shortcuts and input rules are collected
  4. Extensions are initialized in priority order
  5. Editor is ready for editing

Command System

All mutations are commands dispatched through CommandDispatcher:

#![allow(unused)]
fn main() {
pub type Command = fn(&mut Editor) -> Result<(), EditorError>;

pub struct CommandDispatcher {
    commands: HashMap<String, Command>,
}
}

Command Categories

Text Commands:

  • Direct text insertion/deletion
  • Operations on the text content level

Formatting Commands:

  • Toggle marks (bold, italic, etc.)
  • Add/remove marks on ranges
  • Set mark attributes (e.g., link href)

Structure Commands:

  • Change block type (paragraph to heading)
  • Wrap in container (create blockquote)
  • Lift out of container (remove list wrapper)
  • Split/join blocks

Command Execution Flow

User Input (keyboard/UI)
    ↓
Resolve to command name (shortcut or direct call)
    ↓
CommandDispatcher.execute(name)
    ↓
Call command function with &mut Editor
    ↓
Command updates EditorDocument
    ↓
Document mutations trigger Effects
    ↓
DOM nodes update (NodeHandle calls)

Input System

Keyboard Shortcuts

#![allow(unused)]
fn main() {
pub struct KeyboardShortcut {
    pub key: String,              // "Mod-B", "Ctrl+I", etc.
    normalized: String,           // Normalized for matching
    pub description: String,
}

pub struct ShortcutRegistry {
    shortcuts: HashMap<String, String>,  // normalized → command name
}
}

Key parsing:

  • Mod = Ctrl (Windows/Linux) or Cmd (Mac)
  • Separators: - or + (normalized to +)
  • Case-insensitive (normalized to lowercase)

Input Rules

#![allow(unused)]
fn main() {
pub struct InputRule {
    pub pattern: Regex,
    pub description: String,
    pub handler: fn(&mut Editor, &Captures) -> Result<(), EditorError>,
}

pub struct InputRuleSet {
    rules: Vec<InputRule>,
}
}

Examples:

  • Pattern: ^# $ → Convert to Heading 1
  • Pattern: **(.+)\*\*$ → Apply bold mark
  • Pattern: ^---$ → Insert horizontal rule

Input rules run AFTER text insertion, checking if the last line matches a pattern. If matched, the handler modifies the document.

History and Undo/Redo

The History system is designed for collaborative editing:

#![allow(unused)]
fn main() {
pub struct History {
    pub undo_stack: Vec<UndoOperation>,
    pub redo_stack: Vec<UndoOperation>,
}

pub struct UndoOperation {
    pub changes: Vec<DocumentChange>,
    pub inverse: Vec<DocumentChange>,  // Reverses these changes
}
}

Key design:

  • Operations are atomic (multiple DOM changes = one undo step)
  • Inverses are automatically computed
  • Compatible with CRDT merging (local undo doesn’t break collaboration)
  • Redo pushes undone operations back to undo stack

StarterKit: 22 Default Extensions

The StarterKit bundles 22 extensions for full-featured editing:

Nodes (12)

NameGroupContentPurpose
doc-block+Root node
paragraphblockinline*Default text block
textinline-Inline text (atomic)
headingblockinline*h1-h6 with level attr
blockquoteblockblock+Quote wrapper
bullet_listblocklist_item+Unordered list
ordered_listblocklist_item+Numbered list
list_item-block+List entry
code_blockblocktext*Pre-formatted code
horizontal_ruleblock-Visual divider (atomic)
hard_breakinline-Line break (atomic)
imageinline-Image embed (atomic)

Marks (10)

NameShortcutHTMLAttributes
boldMod-B<strong>-
italicMod-I<em>-
underlineMod-U<u>-
strikeMod-Shift-X<s>-
codeMod-E<code>excludes: bold, italic, underline, strike
link-<a>href (required), title, target
highlightMod-Shift-H<mark>color (optional)
subscriptMod-,<sub>excludes: superscript
superscriptMod-.<sup>excludes: subscript
text_color-<span>color (required)

Table Extension

The optional TableExtension adds table editing:

#![allow(unused)]
fn main() {
pub struct TableExtension;

impl Extension for TableExtension {
    fn nodes(&self) -> Vec<NodeSpec> {
        vec![
            NodeSpec::builder("table")      // block container
                .content("table_row+")
                .isolating(true)            // Operations don't cross boundary
                .build(),
            NodeSpec::builder("table_row")
                .content("table_cell+")
                .build(),
            NodeSpec::builder("table_cell")
                .content("block+")
                .attr("colspan", AttrSpec::optional("1"))
                .attr("rowspan", AttrSpec::optional("1"))
                .build(),
            NodeSpec::builder("table_header")
                .content("block+")
                .attr("colspan", AttrSpec::optional("1"))
                .attr("rowspan", AttrSpec::optional("1"))
                .build(),
        ]
    }
}
}

Commands (11 total):

  • insert_table - Create 3x3 table
  • delete_table - Remove table
  • add_row_before, add_row_after, delete_row
  • add_column_before, add_column_after, delete_column
  • merge_cells - Combine selected cells
  • split_cell - Undo colspan/rowspan
  • toggle_header_row - Promote first row

Navigation:

  • Tab = Next cell
  • Shift-Tab = Previous cell

Selection and Positions

Position

#![allow(unused)]
fn main() {
pub struct Position {
    pub offset: usize,  // Byte offset in document
}

impl Position {
    pub fn new(offset: usize) -> Self
}
}

Positions are 0-indexed from document start.

Range

#![allow(unused)]
fn main() {
pub struct Range {
    pub start: Position,
    pub end: Position,
}
}

Selection

#![allow(unused)]
fn main() {
pub struct Selection {
    pub anchor: Position,   // Selection start (fixed)
    pub head: Position,     // Selection end (can move)
}

impl Selection {
    pub fn cursor(pos: Position) -> Self      // anchor == head
    pub fn range(start: Position, end: Position) -> Self
    pub fn is_collapsed(&self) -> bool        // Is it just a cursor?
}
}

Key insight: Even when selecting, both anchor and head point to positions in the document. Cursor operations check is_collapsed().

Rendering Integration

Hidden Textarea Approach

<!-- Editor container -->
<div class="editor">
    <!-- Hidden textarea for input -->
    <textarea style="position: absolute; opacity: 0;"></textarea>

    <!-- Rendered document (from NodeHandle API) -->
    <div class="document">
        <!-- Rendered nodes from editor.doc -->
    </div>

    <!-- Selection overlay -->
    <div class="selection-overlay">
        <!-- Cursor and selection highlights -->
    </div>
</div>

Why hidden textarea?

  • Captures all keyboard input (arrow keys, meta keys, etc.)
  • Full IME support (for CJK input)
  • Native clipboard events (Ctrl-C/V, Cmd-C/V)
  • Works without browser context

Document Rendering

The editor must render its document to Rinch NodeHandles:

#![allow(unused)]
fn main() {
fn render_editor_doc(__scope: &mut RenderScope, editor: &Editor) -> NodeHandle {
    let container = __scope.create_element("div");
    container.set_attribute("class", "editor-doc");

    // Render document nodes
    for node in &editor.doc.nodes {
        let rendered = render_node(__scope, node);
        container.append_child(&rendered);
    }

    container
}

fn render_node(__scope: &mut RenderScope, node: &DocumentNode) -> NodeHandle {
    match node.type_name.as_str() {
        "paragraph" => {
            let p = __scope.create_element("p");
            for child in &node.children {
                let rendered = render_node(__scope, child);
                p.append_child(&rendered);
            }
            p
        }
        "text" => {
            let text = __scope.create_text(&node.content);
            text
        }
        // ... other node types
        _ => __scope.create_text(""),
    }
}
}

Marks Rendering

Marks are applied after text nodes are created:

#![allow(unused)]
fn main() {
// For each mark span in the document:
let span = __scope.create_element("strong");  // For bold
for text in &mark.content {
    span.append_child(&__scope.create_text(text));
}
}

Event Flow

Keyboard Event
    ↓
Hidden Textarea captures (e.g., "a" key)
    ↓
Check InputRules (does "a" complete a pattern?)
    ↓
Insert text in document
    ↓
Check shortcuts (is Mod-B pressed?)
    ↓
Execute command if matched
    ↓
EditorDocument updated
    ↓
Effects trigger DOM updates
    ↓
Render marks and styles

CRDT Collaboration

The Automerge document enables offline editing and collaboration:

Local Editor          Remote Editor
    │                      │
    ├─ Edit locally ────→ Receive update
    │                      ├─ Merge into document
    ├─ Create change  ←─── Send change
    │                      └─ Re-render
    └─ Merge remote

Each change is recorded with metadata (actor, timestamp) allowing automatic merging without conflicts.

Error Handling

#![allow(unused)]
fn main() {
pub enum EditorError {
    InvalidNodeType(String),
    InvalidMarkType(String),
    InvalidSelection,
    DocumentError(String),
    CommandNotFound(String),
    // ...
}
}

All document mutations return Result<(), EditorError>, allowing graceful error handling:

#![allow(unused)]
fn main() {
editor.doc.insert_text(pos, "hello")?;  // Propagate errors
match editor.commands.execute("toggle_bold") {
    Ok(()) => { /* success */ }
    Err(e) => eprintln!("Command failed: {}", e),
}
}

Performance Characteristics

Time Complexity

OperationComplexityNotes
Insert textO(n)n = document length
Delete rangeO(n)Proportional to range size
Toggle markO(n)Scans range for existing marks
Apply input ruleO(1)Regex on last line only
Command dispatchO(log k)k = number of commands
Schema lookupO(1)HashMap

Memory

  • Document size proportional to content
  • CRDT overhead: ~2x text size (metadata)
  • Undo stack: One entry per atomic operation
  • Selection state: Minimal (two positions)

Testing Utilities

Rinch provides test helpers:

#![allow(unused)]
fn main() {
#[cfg(test)]
fn test_example() {
    let schema = Schema::starter_kit();
    let config = EditorConfig::default();
    let mut editor = Editor::new(schema, config).unwrap();

    // Insert and verify
    editor.doc.insert_text(Position::new(0), "hello").unwrap();
    assert_eq!(editor.doc.length(), 5);

    // Toggle formatting
    editor.commands.execute("toggle_bold").unwrap();

    // Check result
    let marks = editor.doc.marks_at(Position::new(2));
    assert!(marks.iter().any(|m| m.name == "bold"));
}
}

Extensibility Points

Custom Extensions

Implement Extension to add:

  • New node types (with content rules)
  • New mark types (with attributes)
  • Commands (via CommandRegistration)
  • Shortcuts (via KeyboardShortcut)
  • Input rules (via InputRule)

Custom Schema

Build your own schema without StarterKit:

#![allow(unused)]
fn main() {
let mut schema = Schema::new();
schema.add_node(my_custom_node);
schema.add_mark(my_custom_mark);
}

Custom Commands

Register commands directly:

#![allow(unused)]
fn main() {
editor.commands.register("my_command", |editor| {
    // Command implementation
    Ok(())
})?;
}

Custom Renderers

Implement rendering for your document structure using Rinch’s NodeHandle API (set_text, set_attribute, append_child, etc.).

rinch

The main rinch crate provides the application entry point, shell runtime, and re-exports commonly used types from rinch-core, rinch-macros, rinch-theme, and rinch-components.

Entry Point

rinch::run

Runs a rinch application with the given root component:

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div {
            h1 { "Hello, rinch!" }
            p { "A lightweight GUI framework for Rust." }
        }
    }
}

fn main() {
    run("My App", 800, 600, app);
}

rinch::run_with_theme

Runs with a theme configuration:

use rinch::prelude::*;

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("cyan".into()),
        default_radius: Some("md".into()),
        ..Default::default()
    };
    run_with_theme("Themed App", 800, 600, app, theme);
}

rinch::run_with_window_props

Runs with full window configuration:

use rinch::prelude::*;

fn main() {
    let props = WindowProps {
        title: "My App".into(),
        width: 1024,
        height: 768,
        borderless: true,
        transparent: true,
        ..Default::default()
    };
    run_with_window_props(app, props, None);
}

Prelude

Import commonly used types with the prelude:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
}

This includes:

Entry points (desktop feature):

  • run, run_with_theme

Element and prop types (from rinch_core::element::*):

  • Element, Children, WindowProps, ThemeProviderProps
  • Callback, SectionRenderer

Menu types (from rinch::menu):

  • Menu, MenuItem — unified builder API for native menus and tray menus

Reactive primitives:

  • Signal, Effect, Memo, Scope
  • batch, derived, untracked

Hooks:

  • Signal::new, Memo::new, Effect::new
  • create_context, use_context, try_use_context

DOM construction:

  • NodeHandle, RenderScope, with_render_scope

Component trait:

  • Component

Control flow:

  • show_dom, FineShowBuilder (conditional rendering)
  • for_each_dom, ForItem, FineForBuilder (list rendering)

Event handling:

  • ClickContext, InputCallback, get_click_context, start_drag

Icons:

  • Icon

Macros:

  • rsx!, #[component]

Window controls (desktop feature):

  • close_current_window, minimize_current_window, toggle_maximize_current_window

Theme types (theme feature):

  • All types from rinch_theme (colors, spacing, radius, etc.)

Component types (components feature):

  • All component structs from rinch_components (Button, TextInput, Stack, Group, etc.)

Macros

#![allow(unused)]
fn main() {
pub use rinch_macros::rsx;        // RSX macro for DOM construction
pub use rinch_macros::component;  // #[component] attribute macro
}

Re-exports

Element Types

#![allow(unused)]
fn main() {
pub use rinch_core::element::{
    Children,
    Element,
    ThemeProviderProps,
    WindowProps,
};
}

Reactive Primitives

#![allow(unused)]
fn main() {
pub use rinch_core::{
    batch,
    derived,
    untracked,
    Effect,
    Memo,
    Scope,
    Signal,
};
}

Sub-crates

#![allow(unused)]
fn main() {
pub use rinch_core as core;
pub use rinch_renderer as renderer;  // desktop feature
}

Modules

rinch::shell

Application runtime and event loop:

  • run() - Entry point function
  • run_with_theme() - Entry point with theme configuration
  • run_with_menu() - Entry point with native menu bar
  • run_with_window_props() - Entry point with full window props
  • run_with_window_props_and_menu() - Entry point with full window props and menu
  • run_rinch(), run_rinch_with_window_props() - Lower-level runtime entry points (deprecated)

rinch::menu

Unified menu builder API for native menus and tray context menus:

  • Menu - Builder with .item(), .separator(), .submenu() methods
  • MenuItem - Builder with .shortcut(), .enabled(), .on_click() methods

rinch::window

Window utilities (currently minimal, window management is in shell).

rinch::windows

Window control functions for custom window chrome:

  • close_current_window()
  • minimize_current_window()
  • toggle_maximize_current_window()

rinch::fine_grained

Fine-grained rendering types (re-exported from rinch-core):

  • NodeHandle, RenderScope

rinch::theme (theme feature)

Theme system types from rinch-theme.

rinch::components (components feature)

Component library from rinch-components.

rinch::dialogs (file-dialogs feature)

File dialog wrappers via rfd.

rinch::clipboard (clipboard feature)

Clipboard operations.

rinch::tray (system-tray feature)

System tray support.

rinch-core

Core types and traits for rinch, including elements, reactive primitives, the DOM abstraction layer, hooks, and the Component trait.

Element Types

Element

The fundamental building block enum. Note that it is minimal - shell-level constructs (windows, menus, themes) are handled at the runtime level, not as Element variants.

#![allow(unused)]
fn main() {
pub enum Element {
    /// Raw HTML content rendered by the DOM backend.
    Html(String),
    /// A fragment containing multiple children.
    Fragment(Children),
    /// A custom component implementing the Component trait.
    Component(Rc<dyn Component>, Children),
}
}

WindowProps

Configuration for a window (used at the runtime level via run_with_window_props):

#![allow(unused)]
fn main() {
pub struct WindowProps {
    pub title: String,
    pub width: u32,
    pub height: u32,
    pub x: Option<i32>,
    pub y: Option<i32>,
    pub borderless: bool,
    pub resizable: bool,
    pub transparent: bool,
    pub always_on_top: bool,
    pub visible: bool,
    pub resize_inset: Option<f32>,
}
}

DOM Abstraction Layer

The fine-grained reactive rendering system is built on three core types that abstract DOM operations away from any specific backend (desktop via Taffy/Parley/Vello, or browser-native via web_sys).

DomDocument Trait

The backend abstraction. All DOM operations go through this trait:

#![allow(unused)]
fn main() {
pub trait DomDocument {
    fn create_element(&self, tag: &str) -> NodeId;
    fn create_text(&self, content: &str) -> NodeId;
    fn set_attribute(&self, node: NodeId, name: &str, value: &str);
    fn remove_attribute(&self, node: NodeId, name: &str);
    fn set_text(&self, node: NodeId, content: &str);
    fn append_child(&self, parent: NodeId, child: NodeId);
    fn insert_before(&self, parent: NodeId, child: NodeId, reference: NodeId);
    fn remove_child(&self, parent: NodeId, child: NodeId);
    fn register_handler(&self, handler: Box<dyn Fn()>) -> HandlerId;
    // ... and more
}
}

RenderScope

Context for building DOM trees. Wraps a DomDocument and provides the API that the rsx! macro calls:

#![allow(unused)]
fn main() {
impl RenderScope {
    pub fn create_element(&mut self, tag: &str) -> NodeHandle;
    pub fn create_text(&mut self, content: &str) -> NodeHandle;
    pub fn register_handler(&mut self, handler: impl Fn() + 'static) -> HandlerId;
}
}

Component functions receive a RenderScope (injected automatically by #[component]):

#![allow(unused)]
fn main() {
#[component]
fn my_component() -> NodeHandle {
    rsx! { div { "Hello" } }
}
// Expands to: fn my_component(__scope: &mut RenderScope) -> NodeHandle { ... }
}

NodeHandle

A stable reference to a DOM node. Delegates all operations via Weak<RefCell<dyn DomDocument>>:

#![allow(unused)]
fn main() {
impl NodeHandle {
    pub fn set_attribute(&self, name: &str, value: &str);
    pub fn remove_attribute(&self, name: &str);
    pub fn set_text(&self, content: &str);
    pub fn append_child(&self, child: &NodeHandle);
    pub fn insert_before(&self, child: &NodeHandle, reference: &NodeHandle);
    pub fn remove_child(&self, child: &NodeHandle);
}
}

NodeHandles are used by Effects for surgical DOM updates:

#![allow(unused)]
fn main() {
// Signal change -> Effect runs -> NodeHandle.set_text() -> Minimal re-layout
}

Component Trait

Components implement the Component trait to render directly to DOM nodes:

#![allow(unused)]
fn main() {
pub trait Component: std::fmt::Debug + 'static {
    fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle;
}
}

Reactive Module

Signal<T>

A reactive container for mutable state. Create signals directly with Signal::new(value).

#![allow(unused)]
fn main() {
impl<T> Signal<T> {
    pub fn new(value: T) -> Self;
    pub fn set(&self, value: T);
    pub fn update(&self, f: impl FnOnce(&mut T));
    pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R;
}

impl<T: Clone> Signal<T> {
    pub fn get(&self) -> T;
}
}

Preferred usage via hooks:

#![allow(unused)]
fn main() {
#[component]
fn counter() -> NodeHandle {
    let count = Signal::new(0);
    rsx! {
        p { {|| count.get().to_string()} }
        button { onclick: move || count.update(|n| *n += 1), "+" }
    }
}
}

Effect

A side-effect that tracks signal dependencies and re-runs when they change.

#![allow(unused)]
fn main() {
impl Effect {
    pub fn new<F: FnMut() + 'static>(f: F) -> Self;
    pub fn new_deferred<F: FnMut() + 'static>(f: F) -> Self;
    pub fn run(&self);
    pub fn dispose(&self);
}
}

In practice, Effects are created automatically by the rsx! macro for reactive expressions ({|| expr}), and by Effect::new() for explicit side effects.

Memo<T>

A cached computed value that automatically re-computes when its dependencies change.

#![allow(unused)]
fn main() {
impl<T: Clone + 'static> Memo<T> {
    pub fn new<F: Fn() -> T + 'static>(f: F) -> Self;
    pub fn get(&self) -> T;
}
}

Scope

Manages the lifetime of reactive primitives.

#![allow(unused)]
fn main() {
impl Scope {
    pub fn new() -> Self;
    pub fn run<R>(&self, f: impl FnOnce() -> R) -> R;
    pub fn add_effect(&self, effect: Effect);
    pub fn dispose(&self);
}
}

Reactive Primitives

PrimitivePurpose
Signal::new(value)Reactive state container
Effect::new(closure)Side effects with auto-tracked dependencies
Memo::new(closure)Cached computed values
create_context(value)Create shared context
use_context::<T>()Access shared context (panics if missing)
try_use_context::<T>()Access shared context (returns Option)

Utility Functions

batch

Batch multiple signal updates to defer effect execution:

#![allow(unused)]
fn main() {
pub fn batch<R>(f: impl FnOnce() -> R) -> R;
}

derived

Create a memo (convenience function):

#![allow(unused)]
fn main() {
pub fn derived<T: Clone + 'static>(f: impl Fn() -> T + 'static) -> Memo<T>;
}

untracked

Read signals without tracking dependencies:

#![allow(unused)]
fn main() {
pub fn untracked<R>(f: impl FnOnce() -> R) -> R;
}

Control Flow

show_dom / Show

Reactive conditional rendering. Swaps DOM content when the condition changes:

#![allow(unused)]
fn main() {
#[component]
fn example() -> NodeHandle {
    let visible = Signal::new(true);
    rsx! {
        Show {
            when: {move || visible.get()},
            div { "Visible!" }
        }
    }
}
}

for_each_dom / For

Keyed list rendering with minimal DOM operations via LIS-based reconciliation:

#![allow(unused)]
fn main() {
#[component]
fn example() -> NodeHandle {
    let items = Signal::new(vec!["a", "b", "c"]);
    rsx! {
        For {
            each: {move || items.get().into_iter().map(|s| ForItem::new(s, s)).collect()},
            |item: &ForItem| rsx! { div { {item.downcast::<&str>().unwrap()} } }
        }
    }
}
}

rinch-macros

Procedural macros for rinch: the rsx! macro for building UI and the #[component] attribute macro for ergonomic component definitions.

rsx!

A JSX-like macro for building UI elements via DOM construction.

Basic Usage

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div { class: "container",
            h1 { "Hello, World!" }
            p { "Welcome to rinch." }
        }
    }
}
}

Syntax

Elements

HTML elements use lowercase names:

#![allow(unused)]
fn main() {
rsx! {
    div { }
    span { }
    button { }
    input { }
}
}

Rinch components and control-flow use PascalCase:

#![allow(unused)]
fn main() {
rsx! {
    Button { label: "Click me" }
    TextInput { placeholder: "Enter text..." }
    Stack { }
    Group { }
    Show { when: {move || visible.get()}, div { "Visible!" } }
    For { each: {move || items.get()}, |item: &ForItem| rsx! { div { } } }
    Fragment { }
}
}

Attributes

Attributes are key-value pairs before children:

#![allow(unused)]
fn main() {
rsx! {
    div { class: "container", id: "main",
        // children
    }
}
}

Text Content

Text is included directly:

#![allow(unused)]
fn main() {
rsx! {
    p { "Hello, World!" }
    span { "Multiple " "strings " "work" }
}
}

Static Expressions

Rust expressions in curly braces are captured once at render time:

#![allow(unused)]
fn main() {
let name = "World";
rsx! {
    p { "Hello, " {name} "!" }
}
}

Reactive Expressions

Use closure syntax {|| expr} for values that update automatically when signals change:

#![allow(unused)]
fn main() {
let count = Signal::new(0);

rsx! {
    // Static - captured once, never updates
    p { {count.get().to_string()} }

    // Reactive - creates an Effect, updates when count changes
    p { {|| count.get().to_string()} }

    // Reactive attribute
    div { class: {|| if count.get() > 5 { "high" } else { "low" }}, "Value" }

    // Reactive style
    div { style: {|| format!("width: {}px", count.get() * 10)}, "Bar" }
}
}

Event Handlers

Events use onevent: handler syntax with no-argument closures:

#![allow(unused)]
fn main() {
rsx! {
    button {
        onclick: || println!("Clicked!"),
        "Click me"
    }
}
}

Expansion

The rsx! macro expands to DOM construction code using RenderScope and NodeHandle. It does not generate HTML strings or Element enum variants.

#![allow(unused)]
fn main() {
// This:
rsx! {
    div { class: "wrapper",
        p { "Hello" }
    }
}

// Expands to approximately:
{
    let __elem0 = __scope.create_element("div");
    __elem0.set_attribute("class", "wrapper");
    let __elem1 = __scope.create_element("p");
    let __text0 = __scope.create_text("Hello");
    __elem1.append_child(&__text0);
    __elem0.append_child(&__elem1);
    __elem0
}
}

Reactive expressions expand to Effects that surgically update DOM nodes:

#![allow(unused)]
fn main() {
// This:
rsx! { p { {|| count.get().to_string()} } }

// Creates an Effect that calls node.set_text() when the signal changes
}

PascalCase components invoke the component’s render() method or the component function, passing __scope and any children.

Notes

  • The macro requires __scope: &mut RenderScope to be in scope (provided automatically by #[component])
  • HTML elements become create_element() calls, not HTML strings
  • Component props use default values where not specified
  • The macro is compile-time, so syntax errors appear at build time
  • Helpful error messages include typo suggestions for misspelled attributes

#[component] Attribute Macro

Auto-injects __scope: &mut RenderScope as the first parameter of a component function. This is the recommended way to define components.

Usage

#![allow(unused)]
fn main() {
use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! { div { "Hello!" } }
}
// Expands to: fn app(__scope: &mut RenderScope) -> NodeHandle { ... }

// With extra parameters:
#[component]
fn card(title: &str) -> NodeHandle {
    rsx! { div { {title} } }
}
// Expands to: fn card(__scope: &mut RenderScope, title: &str) -> NodeHandle { ... }
}

Why Use It

  • Eliminates boilerplate: no need to manually write __scope: &mut RenderScope
  • Makes component signatures cleaner and focused on the component’s own props
  • Works with any number of additional parameters
  • The rsx! macro requires __scope to be in scope, and #[component] provides it automatically