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" }
    }
}
}

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..."
}

// 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
#![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

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

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>None
max_rowsOption<u32>None
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.

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.

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
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.

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)

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

WASM targets live outside the main workspace because Parley’s fontconfig dependency doesn’t cross-compile to wasm32. 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 = ["web", "components", "theme"] }
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::*;

#[wasm_bindgen(start)]
pub fn main() {
    console_error_panic_hook::set_once();
    // Mount to browser DOM — see examples/ui-zoo-web for the full pattern
}

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.

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);
}

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, the menu appears in the window (similar to Windows) unless a global menu system is available.

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()).

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.

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)
layout_size()Get current (width, height) from CSS layout
set_texture_source(view, w, h)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 once, call update() each frame.

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
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

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)

ContentEditable API

Rinch provides a DOM-level contenteditable system for building rich-text editors. This is the low-level API that powers all text editing — the contenteditable HTML attribute on a <div>, combined with the ContentEditableApi trait for programmatic control.

Two layers: The Rich-Text Editor guide covers the higher-level rinch-editor crate (schemas, extensions, document model). This guide covers the lower-level CE API that it’s built on. Most users will use both — CE for the editing surface, and rinch-editor for serialization/collaboration.

Quick Start

Create a contenteditable element and interact with it:

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

#[component]
fn my_editor() -> NodeHandle {
    rsx! {
        div {
            // Toolbar
            div {
                button { onclick: move || ce_do(|api| api.toggle_wrap("strong")), "Bold" }
                button { onclick: move || ce_do(|api| api.toggle_wrap("em")), "Italic" }
                button { onclick: move || ce_do(|api| api.set_block_type("h1")), "H1" }
            }
            // Editing surface
            div {
                contenteditable: "true",
                style: "min-height: 200px; padding: 8px; border: 1px solid #ccc;",
            }
        }
    }
}

/// Helper: run a closure on the currently focused CE element.
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
    with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
}

Setting contenteditable: "true" on a <div> activates rinch’s built-in editing behavior: cursor rendering, text selection, keyboard input handling, clipboard support, and undo/redo.

How It Works

Keyboard Event
    ↓
InputHandler maps key → EditCommand
    ↓
CeOps mutates EditorDocument (CRDT, source of truth)
    ↓
Affected DOM blocks re-rendered from EditorDocument state
    ↓
CeEvent dispatched to all listeners
    ↓
SelectionChanged + oninput fired
    ↓
Scene marked dirty → repaint
  1. The rinch runtime captures keyboard events on the focused CE element
  2. Keys are mapped to EditCommands (InsertText, DeleteBackward, ToggleBold, etc.)
  3. CeOps — the runtime’s implementation of ContentEditableApi — mutates the EditorDocument (Automerge CRDT)
  4. Only the affected block(s) are re-rendered in the DOM from EditorDocument state
  5. Each mutation dispatches a CeEvent so observers can react
  6. The cursor/selection is updated and the scene is repainted

CRDT-first architecture: Every mutation flows through EditorDocument first, then the DOM is updated as a view. This ensures the CRDT and DOM never diverge, and makes collaboration work automatically — remote changes just load into EditorDocument, then re-render.

ContentEditableApi Trait

The ContentEditableApi trait is the single mutation interface for all CE operations. Every method mutates the DOM and dispatches a corresponding CeEvent.

Text Operations

#![allow(unused)]
fn main() {
fn insert_text(&mut self, text: &str);     // Insert at cursor
fn delete_backward(&mut self);              // Backspace
fn delete_forward(&mut self);               // Delete key
fn delete_selection(&mut self);             // Delete selected range
}

Block Structure

#![allow(unused)]
fn main() {
fn split_block(&mut self);                  // Enter key — split block at cursor
fn set_block_type(&mut self, tag: &str);    // Change block to h1, p, blockquote, etc.
}

Inline Formatting

#![allow(unused)]
fn main() {
fn wrap_selection(&mut self, tag: &str);    // Wrap in <strong>, <em>, etc.
fn unwrap_selection(&mut self, tag: &str);  // Remove formatting wrapper
fn toggle_wrap(&mut self, tag: &str);       // Toggle on/off
}

Supported tags: "strong", "em", "u", "s", "code".

List Operations

#![allow(unused)]
fn main() {
fn indent(&mut self);    // Convert to list item or increase nesting
fn outdent(&mut self);   // Decrease nesting or convert from list item
}

Selection

#![allow(unused)]
fn main() {
fn get_selection(&self) -> CeSelection;
fn set_selection(&mut self, sel: CeSelection);
}

Undo/Redo

#![allow(unused)]
fn main() {
fn undo(&mut self);
fn redo(&mut self);
}

Query Methods

#![allow(unused)]
fn main() {
fn has_active_mark(&self, tag: &str) -> bool;   // Is cursor inside <strong>, <em>, etc.?
fn cursor_block_tag(&self) -> Option<String>;    // Block tag at cursor ("p", "h1", "ul", etc.)
}

Content Interchange

#![allow(unused)]
fn main() {
fn extract_content(&self) -> Vec<BlockData>;         // Read content as structured blocks
fn load_content(&mut self, blocks: &[BlockData]);     // Replace content from blocks
fn load_html(&mut self, html: &str);                  // Replace content from HTML string
fn clear_formatting(&mut self);                        // Strip all inline formatting
}

Accessing the CE API

There are two ways to access the CE API, depending on whether you need to target the focused element or a specific element.

Active CE API (Focused Element)

Use with_active_ce_api() to operate on whichever CE element currently has focus. This is the typical pattern for toolbar buttons:

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

// Helper function (recommended pattern)
fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
    with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}

// In toolbar buttons:
button { onclick: move || ce_do(|api| api.toggle_wrap("strong")), "Bold" }
button { onclick: move || ce_do(|api| api.set_block_type("h2")), "H2" }
button { onclick: move || ce_do(|api| api.undo()), "Undo" }
}

Returns None if no CE element is focused.

Per-Element CE API (NodeHandle)

Use NodeHandle::with_ce_api() to target a specific CE element. This works whether or not the element has focus — useful for loading initial content:

#![allow(unused)]
fn main() {
let editor_div = rsx! {
    div { contenteditable: "true" }
};

// Load content into a specific CE element
editor_div.with_ce_api(|api| {
    api.borrow_mut().load_html("<p>Hello <strong>world</strong></p>");
});
}

Per-Node Registry

For advanced use cases, access CE APIs by node ID:

#![allow(unused)]
fn main() {
use rinch_core::ce::{with_ce_api_for_node, register_ce_api, unregister_ce_api};

// Access CE API for a known node ID
with_ce_api_for_node(node_id, |api| {
    api.borrow_mut().insert_text("Hello");
});
}

CeEvent System

Every CE mutation dispatches a CeEvent to all subscribed listeners. This is how the editor bridge stays in sync with DOM changes.

Subscribing to Events

#![allow(unused)]
fn main() {
use rinch_core::ce::{subscribe_ce_events, CeEvent};
use std::rc::Rc;

subscribe_ce_events(Rc::new(move |event| {
    match event {
        CeEvent::TextInserted { node_id, offset, text } => {
            println!("Inserted '{}' at node {} offset {}", text, node_id, offset);
        }
        CeEvent::TextDeleted { node_id, offset, length } => {
            println!("Deleted {} bytes at node {} offset {}", length, node_id, offset);
        }
        CeEvent::SelectionChanged { selection } => {
            // Update toolbar active states, etc.
        }
        _ => {}
    }
}));
}

Event Reference

Text Mutations

EventFieldsWhen
TextInsertednode_id, offset, textText inserted at cursor
TextDeletednode_id, offset, lengthText deleted (backspace, delete, selection)
TextNodeCreatednode_id, parent_id, textNew text node created (e.g. first char in empty block)
NodeRemovednode_id, parent_idA DOM node was removed

Selection

EventFieldsWhen
SelectionChangedselection: CeSelectionCursor moved or selection changed

Block Structure

EventFieldsWhen
BlockSplitoriginal_block_id, new_block_id, split_offsetEnter key splits a block
BlockJoinedsurviving_block_id, removed_block_id, merge_offsetBackspace joins two blocks
BlockTypeChangedold_node_id, new_node_id, old_tag, new_tagBlock type changed (e.g. p → h1)

Inline Formatting

EventFieldsWhen
SelectionWrappedtag, wrapper_node_id, wrapped_node_idsSelection wrapped in formatting element
SelectionUnwrappedtag, unwrapped_node_idsFormatting removed from selection

List Structure

EventFieldsWhen
ListItemOutdentedold_li_id, new_block_idList item outdented
BlockIndentedold_block_id, new_li_id, list_idBlock indented into list

Tables

EventFieldsWhen
TableInsertedblock_node_id, rows, colsTable inserted
TableDeletedblock_node_idTable removed

History & Clipboard

EventFieldsWhen
UndoApplied(none)Undo operation completed
RedoApplied(none)Redo operation completed
HtmlPastedcreated_node_idsHTML pasted from clipboard

DomCursor and CeSelection

The CE system uses DOM-level cursor positions, not document-level byte offsets.

#![allow(unused)]
fn main() {
/// A position in the DOM: which text node, and byte offset within it.
pub struct DomCursor {
    pub node_id: usize,   // ID of the text node (or element for empty blocks)
    pub offset: usize,    // Byte offset within the text node
}

/// A selection: anchor (where it started) + head (current caret position).
pub struct CeSelection {
    pub anchor: DomCursor,
    pub head: DomCursor,
}

impl CeSelection {
    fn collapsed(cursor: DomCursor) -> Self  // Cursor (no selection)
    fn range(anchor: DomCursor, head: DomCursor) -> Self
    fn is_collapsed(&self) -> bool
}
}

Key difference from document positions: DomCursor references specific DOM node IDs, not abstract document offsets. Node IDs can change when the DOM is restructured (e.g. block splits, formatting changes).

Data Interchange Types

Use BlockData for structured content interchange (e.g. syncing with EditorDocument):

#![allow(unused)]
fn main() {
pub struct BlockData {
    pub block_type: String,                    // "paragraph", "heading", etc.
    pub attrs: HashMap<String, String>,        // e.g. {"level": "2"} for headings
    pub content: Vec<InlineRunData>,
}

pub struct InlineRunData {
    pub text: String,
    pub marks: Vec<InlineMarkData>,
}

pub struct InlineMarkData {
    pub mark_type: String,                     // "bold", "italic", "code", etc.
    pub attrs: HashMap<String, String>,
}
}

Keyboard Shortcuts

The CE system provides these built-in keyboard shortcuts:

Text Formatting

ShortcutAction
Ctrl+BToggle bold (<strong>)
Ctrl+IToggle italic (<em>)
Ctrl+UToggle underline (<u>)
Ctrl+Shift+XToggle strikethrough (<s>)
Ctrl+EToggle inline code (<code>)

Editing

ShortcutAction
EnterSplit block
BackspaceDelete backward (joins blocks at boundary)
DeleteDelete forward
TabIndent (increase list nesting or insert tab)
Shift+TabOutdent (decrease list nesting)
Ctrl+ZUndo
Ctrl+YRedo

Clipboard

ShortcutAction
Ctrl+CCopy selection
Ctrl+XCut selection
Ctrl+VPaste (HTML preferred, falls back to plain text)

All standard cursor movement keys work: arrow keys, Home/End, Ctrl+arrow for word movement, Shift+arrow for selection, Ctrl+A for select all, Page Up/Down.

Building a Toolbar

A typical pattern for editor toolbars uses reactive signals to track active formatting state:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch_core::ce::{with_active_ce_api, subscribe_ce_events, CeEvent};

#[component]
fn editor_toolbar() -> NodeHandle {
    let is_bold = Signal::new(false);
    let is_italic = Signal::new(false);
    let block_type = Signal::new(String::from("p"));

    // Subscribe to selection changes to update toolbar state
    subscribe_ce_events(Rc::new(move |event| {
        if let CeEvent::SelectionChanged { .. } = event {
            with_active_ce_api(|api| {
                let api = api.borrow();
                is_bold.set(api.has_active_mark("strong"));
                is_italic.set(api.has_active_mark("em"));
                if let Some(tag) = api.cursor_block_tag() {
                    block_type.set(tag);
                }
            });
        }
    }));

    rsx! {
        div { class: "toolbar",
            button {
                class: {|| if is_bold.get() { "active" } else { "" }},
                onclick: move || ce_do(|api| api.toggle_wrap("strong")),
                "B"
            }
            button {
                class: {|| if is_italic.get() { "active" } else { "" }},
                onclick: move || ce_do(|api| api.toggle_wrap("em")),
                "I"
            }
        }
    }
}

fn ce_do(f: impl FnOnce(&mut dyn ContentEditableApi) + 'static) {
    with_active_ce_api(|api| f(&mut *api.borrow_mut()));
}
}

Loading Initial Content

Use load_html to set the initial content of a CE element:

#![allow(unused)]
fn main() {
#[component]
fn editor_with_content() -> NodeHandle {
    let editor = rsx! {
        div { contenteditable: "true", style: "min-height: 200px;" }
    };

    // Load content (works before the element receives focus)
    editor.with_ce_api(|api| {
        api.borrow_mut().load_html(r#"
            <h1>Welcome</h1>
            <p>This is <strong>rich text</strong> content.</p>
            <ul>
                <li>First item</li>
                <li>Second item</li>
            </ul>
        "#);
    });

    editor
}
}

Extracting Content

Read the current CE content as structured data:

#![allow(unused)]
fn main() {
// As BlockData (for syncing with EditorDocument or serialization)
let blocks = with_active_ce_api(|api| {
    api.borrow().extract_content()
}).unwrap_or_default();

// Process blocks
for block in &blocks {
    println!("Block type: {}", block.block_type);
    for run in &block.content {
        let marks: Vec<_> = run.marks.iter().map(|m| m.mark_type.as_str()).collect();
        println!("  '{}' marks={:?}", run.text, marks);
    }
}
}

Architecture Notes

CeOps

CeOps is the runtime’s implementation of ContentEditableApi. It holds a reference to the RinchDocument and the CE element’s node ID. Created lazily when a CE element first receives focus.

Event Flow

User types "a"
  → KeyEvent captured by winit
  → RinchApp::handle_contenteditable_key()
  → InputHandler maps to EditCommand::InsertText("a")
  → CeOps::insert_text("a")
    → DOM: set_text_content on text node
    → dispatch_ce_event(TextInserted { ... })
  → dispatch SelectionChanged
  → dispatch oninput
  → mark scene dirty → repaint

Thread-Local Storage

The CE system uses thread-local storage for global access:

  • Event dispatcher: dispatch_ce_event() / subscribe_ce_events() — broadcasts events to all listeners
  • Active CE API: set_active_ce_api() / with_active_ce_api() — tracks which CE element has focus
  • CE API registry: register_ce_api() / with_ce_api_for_node() — per-element API lookup

All are thread_local! — safe for single-threaded GUI but not shareable across threads.

Key Source Files

FilePurpose
crates/rinch-core/src/ce.rsCore types: CeEvent, ContentEditableApi, DomCursor, CeSelection, dispatchers
crates/rinch/src/ce_ops.rsCeOps — runtime implementation of ContentEditableApi (CRDT-first mutations)
crates/rinch/src/ce_render.rsBlock rendering, BlockMap, position conversion (EditorPosition ↔ DomCursor)
crates/rinch/src/app/contenteditable/mod.rsKeyboard input handler, cursor management
crates/rinch/src/app/contenteditable/ce_selection.rsSelection, copy/cut, HTML extraction
crates/rinch/src/app/contenteditable/ce_paste.rsHTML paste handling
crates/rinch/src/app/contenteditable/ce_navigation.rsCursor navigation
crates/rinch/src/app/contenteditable/ce_virtualization.rsLarge document virtualization
crates/rinch-editable/src/Generic editing primitives (EditCommand, InputHandler)

Rich-Text Editor

Rinch provides a comprehensive rich-text editor with collaborative editing support through Automerge CRDT, a schema-driven document model, and a powerful extension system.

Looking for the ContentEditable API? This guide covers the high-level rinch-editor crate (schemas, extensions, document model). For the lower-level DOM editing API (ContentEditableApi, CeEvent, DomCursor), see the ContentEditable API guide. The editor uses the CE API internally — the CE layer handles DOM mutations while this layer handles document structure and serialization.

Quick Start

Create a new editor with the StarterKit (22 default extensions):

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

#[component]
fn editor_component() -> NodeHandle {
    // Create schema with all standard editing features
    let schema = Schema::starter_kit();
    let config = EditorConfig::default();

    // Create editor instance
    let mut editor = Editor::new(schema, config)?;

    // Render editor content...
    todo!()
}
}

Editor Configuration

The Editor struct holds the document model, schema, selection state, and all editing capabilities:

#![allow(unused)]
fn main() {
pub struct Editor {
    pub doc: EditorDocument,           // The document being edited
    pub schema: Schema,                // Validation rules
    pub selection: SelectionState,     // Current cursor/selection
    pub history: History,              // Undo/redo
    pub commands: CommandDispatcher,   // All mutations
    pub extensions: ExtensionRegistry, // Loaded extensions
    pub input_rules: InputRuleSet,     // Auto-transforms
    pub shortcuts: ShortcutRegistry,   // Keyboard bindings
    pub events: EventDispatcher,       // Event handling
    pub config: EditorConfig,          // Editor settings
}
}

Configuration Options

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct EditorConfig {
    pub autofocus: AutoFocus,  // Focus behavior on mount
    pub editable: bool,        // Allow editing
}

pub enum AutoFocus {
    Start,  // Focus at document start
    End,    // Focus at document end
    None,   // Don't auto-focus (default)
}
}

StarterKit Extensions

The StarterKit provides 22 pre-configured extensions covering all standard editing operations. These are organized into node and mark extensions.

Node Extensions (12 types)

ExtensionTagPurpose
DocumentdocRoot container (content: block+)
Paragraph<p>Default text block (content: inline*)
Text-Inline text content
Heading<h1>-<h6>Section headings with level attribute
Blockquote<blockquote>Quoted content (content: block+)
Bullet List<ul>Unordered list (content: list_item+)
Ordered List<ol>Numbered list (content: list_item+)
List Item<li>List entry (content: block+)
Code Block<pre><code>Fenced code with language attr
Horizontal Rule<hr>Visual divider (atomic)
Hard Break<br>Line break (inline, atomic)
Image<img>Embedded image (requires src)

Mark Extensions (10 types)

ExtensionHTMLShortcutPurpose
Bold<strong>Mod-BBold text
Italic<em>Mod-IItalic text
Underline<u>Mod-UUnderlined text
Strikethrough<s>Mod-Shift-XStruck text
Code<code>Mod-EInline code
Link<a href>-Clickable links
Highlight<mark>Mod-Shift-HHighlighted text
Subscript<sub>Mod-,x₂ subscript
Superscript<sup>Mod-.x² superscript
Text Color<span>-Colored text (requires color attr)

Using StarterKit

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

// Create schema with all 22 extensions
let schema = Schema::starter_kit();

// Or manually load extensions
let mut editor = Editor::new(schema, EditorConfig::default())?;
for ext in StarterKit::extensions() {
    editor.extensions.register(ext)?;
}
}

Keyboard Shortcuts

All StarterKit extensions come with keyboard shortcuts. Here’s the complete reference:

Text Formatting

ShortcutAction
Mod-BToggle bold
Mod-IToggle italic
Mod-UToggle underline
Mod-Shift-XToggle strikethrough
Mod-EToggle code
Mod-Shift-HToggle highlight
Mod-,Toggle subscript
Mod-.Toggle superscript

Note: “Mod” means Ctrl on Windows/Linux and Cmd on macOS.

Block Structure

ShortcutAction
Mod-Alt-0Convert to paragraph
Mod-Alt-1Convert to heading 1
Mod-Alt-2Convert to heading 2
Mod-Alt-3Convert to heading 3
Mod-Alt-4Convert to heading 4
Mod-Alt-5Convert to heading 5
Mod-Alt-6Convert to heading 6

Lists and Quotes

ShortcutAction
Mod-Shift-BToggle blockquote
Mod-Shift-8Toggle bullet list
Mod-Shift-7Toggle ordered list

Special

ShortcutAction
Mod-Alt-CToggle code block
Shift-EnterInsert hard break

Markdown Input Rules

StarterKit includes markdown-style input rules that auto-convert patterns to formatted content:

Headings

Type any of these at the start of a line, followed by space:

# <space>     → Heading 1
## <space>    → Heading 2
### <space>   → Heading 3
#### <space>  → Heading 4
##### <space> → Heading 5
###### <space> → Heading 6

Lists

- <space>  → Bullet list (dash)
* <space>  → Bullet list (asterisk)
1. <space> → Ordered list

Quotes and Code

> <space>  → Blockquote
```<space> → Code block
---        → Horizontal rule

Inline Formatting

These patterns auto-toggle marks while typing:

**text**   → Bold
*text*     → Italic
~~text~~   → Strikethrough

Commands

All mutations go through the command system. Commands are registered by extensions and dispatched by name:

#![allow(unused)]
fn main() {
// Toggle bold on current selection
editor.commands.execute("toggle_bold")?;

// Set heading level
editor.commands.execute("set_heading_1")?;

// Insert elements
editor.commands.execute("insert_table")?;
}

Command Categories

Commands are organized into three categories:

Text Commands

  • insert_text(text) - Insert characters
  • delete_range(range) - Delete text range
  • replace_range(range, text) - Replace with text

Formatting Commands

  • toggle_mark(mark) - Toggle mark on selection
  • remove_mark(mark) - Remove mark from selection
  • set_mark(mark, attrs) - Set mark with attributes

Structure Commands

  • set_block_type(type) - Change node type
  • wrap_in(type) - Wrap in container
  • lift() - Lift out of container
  • split_block() - Split at cursor
  • join_blocks() - Merge adjacent blocks

Table Editing

The optional TableExtension provides full table editing support with 11 commands:

Table Commands

CommandPurpose
insert_tableInsert 3x3 table
delete_tableRemove table
add_row_beforeInsert row before cursor
add_row_afterInsert row after cursor
delete_rowDelete current row
add_column_beforeInsert column before cursor
add_column_afterInsert column after cursor
delete_columnDelete current column
merge_cellsMerge selected cells
split_cellSplit cell (if colspan/rowspan > 1)
toggle_header_rowPromote/demote first row as header

Table Navigation

ShortcutAction
TabMove to next cell
Shift-TabMove to previous cell

Table Structure

Tables contain:

  • <table> - Container (group: block)
  • <table_row> - Row (content: table_cell+) |
  • <table_cell> - Cell (content: block+, attrs: colspan, rowspan)
  • <table_header> - Header cell (content: block+, attrs: colspan, rowspan)

Enable tables:

#![allow(unused)]
fn main() {
let mut editor = Editor::new(Schema::starter_kit(), config)?;
editor.extensions.register(Box::new(TableExtension))?;
}

Document Model

The editor document is backed by Automerge CRDT, enabling offline editing and automatic conflict resolution:

#![allow(unused)]
fn main() {
pub struct EditorDocument {
    // Automerge-backed CRDT document
}

impl EditorDocument {
    // Insert text at position
    pub fn insert_text(&mut self, pos: Position, text: &str) -> Result<(), EditorError>

    // Delete text range
    pub fn delete_range(&mut self, range: Range) -> Result<(), EditorError>

    // Add mark (formatting) to range
    pub fn add_mark(&mut self, range: Range, mark: &str, attrs: Option<HashMap<String, String>>) -> Result<(), EditorError>

    // Remove mark from range
    pub fn remove_mark(&mut self, range: Range, mark: &str) -> Result<(), EditorError>

    // Split block at position
    pub fn split_block(&mut self, pos: Position) -> Result<(), EditorError>

    // Get block type at position
    pub fn block_type(&self, pos: usize) -> Option<String>

    // Get document length
    pub fn length(&self) -> usize
}
}

Position and Range

#![allow(unused)]
fn main() {
// Position: byte offset in document (0 = start)
pub struct Position {
    pub offset: usize,
}

// Range: contiguous span from start to end
pub struct Range {
    pub start: Position,
    pub end: Position,
}
}

Schema System

The schema defines what content is valid. Every extension contributes node and mark specifications:

#![allow(unused)]
fn main() {
// Create a custom schema
let mut schema = Schema::new();

// Add node type
schema.add_node(NodeSpec::builder("paragraph")
    .content("inline*")
    .group("block")
    .build());

// Add mark type
schema.add_mark(MarkSpec::simple("bold"));
}

Node Specs

#![allow(unused)]
fn main() {
pub struct NodeSpec {
    pub name: String,
    pub content: Option<String>,    // Content model (e.g., "inline*", "block+")
    pub group: Option<String>,      // Grouping (block, inline)
    pub attrs: HashMap<String, AttrSpec>,
    pub inline: bool,               // Inline vs block
    pub atom: bool,                 // Atomic (no content)
    pub isolating: bool,            // Boundary for operations
    pub marks: MarkSet,             // Which marks allowed
    pub parse_html_tags: Vec<String>,
}
}

Mark Specs

#![allow(unused)]
fn main() {
pub struct MarkSpec {
    pub name: String,
    pub attrs: HashMap<String, AttrSpec>,
    pub parse_html_tags: Vec<String>,
    pub inclusive: bool,            // Extend to typing (default: true)
    pub excludes: Option<String>,   // Conflicts (e.g., "bold italic")
}
}

Selection and Cursor

The selection tracks the current cursor position or selection range:

#![allow(unused)]
fn main() {
pub struct Selection {
    pub anchor: Position,    // Selection start
    pub head: Position,      // Selection end (same as anchor for cursor)
}

impl Selection {
    // Cursor at position
    pub fn cursor(pos: Position) -> Self

    // Range from start to end
    pub fn range(start: Position, end: Position) -> Self

    // Check if collapsed (cursor, not range)
    pub fn is_collapsed(&self) -> bool
}

// Update selection
editor.set_selection(Selection::cursor(Position::new(10)));
}

Undo/Redo

Rinch editor includes local undo/redo for collaborative editing. Operations are stored with inverses for reversal:

#![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>,  // Undo reverses these
}

// Undo last operation
editor.history.undo();

// Redo last undone operation
editor.history.redo();
}

Each operation is atomic - undo/redo are single steps regardless of how many DOM mutations happened.

Extension System

Create custom extensions by implementing the Extension trait:

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

#[derive(Debug)]
struct MyCustomExtension;

impl Extension for MyCustomExtension {
    fn name(&self) -> &str {
        "my_custom"
    }

    fn nodes(&self) -> Vec<NodeSpec> {
        vec![NodeSpec::builder("custom_node")
            .content("inline*")
            .group("block")
            .build()]
    }

    fn marks(&self) -> Vec<MarkSpec> {
        vec![MarkSpec::simple("custom_mark")]
    }

    fn commands(&self) -> Vec<CommandRegistration> {
        vec![CommandRegistration::new("my_command", |editor| {
            // Command logic here
            Ok(())
        })]
    }

    fn keyboard_shortcuts(&self) -> Vec<(KeyboardShortcut, String)> {
        vec![(
            KeyboardShortcut::new("Mod-K", "My command"),
            "my_command".into(),
        )]
    }

    fn input_rules(&self) -> Vec<InputRule> {
        vec![InputRule::new(
            regex::Regex::new(r"^:wave: $").unwrap(),
            "Wave emoji",
            |_editor, _caps| Ok(()),
        )]
    }
}

// Register extension
editor.extensions.register(Box::new(MyCustomExtension))?;
}

Extension Priorities

Extensions can specify priority (lower = earlier initialization):

#![allow(unused)]
fn main() {
impl Extension for MyExtension {
    fn priority(&self) -> i32 {
        50  // Lower than default (100)
    }
}
}

Use priorities to control initialization order when extensions have dependencies.

Events

The editor dispatches events for state changes:

#![allow(unused)]
fn main() {
pub struct EventDispatcher {
    // Event routing system
}

// Listen to updates
editor.events.on("update", |event| {
    // Handle update
});
}

Testing

Rinch provides testing utilities:

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

#[test]
fn test_bold_toggle() {
    let schema = Schema::starter_kit();
    let mut editor = Editor::new(schema, EditorConfig::default()).unwrap();

    // Insert text
    editor.doc.insert_text(Position::new(0), "hello").unwrap();

    // Toggle bold
    editor.commands.execute("toggle_bold").unwrap();

    // Verify mark applied
    let marks = editor.doc.marks_at(Position::new(2));
    assert!(marks.iter().any(|m| m.name == "bold"));
}
}

Advanced: Custom Document Rendering

For embedding the editor in your Rinch app, implement DOM rendering:

#![allow(unused)]
fn main() {
#[component]
fn render_editor(editor: &Editor) -> NodeHandle {
    let div = __scope.create_element("div");
    div.set_attribute("class", "editor");

    // Render document
    for node in editor.doc.nodes() {
        let rendered = render_node(__scope, node);
        div.append_child(&rendered);
    }

    // Render cursor/selection
    if let Some(cursor_node) = render_cursor(__scope, &editor.selection) {
        div.append_child(&cursor_node);
    }

    div
}
}

Architecture

The editor uses a Hidden Textarea + Virtual DOM approach:

  1. A hidden <textarea> captures keyboard input, IME, and clipboard
  2. The document model (Automerge CRDT) is the source of truth
  3. DOM nodes are rendered from the document using Rinch’s NodeHandle API
  4. Selection and cursor overlays provide visual feedback

This design enables:

  • Full IME support (Chinese, Japanese, etc.)
  • Native clipboard handling
  • Undo/redo compatible with collaborative editing
  • Surgical DOM updates (only changed nodes update)

See Editor Architecture for deep technical details.

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/          # WASM backend stubs
│   ├── 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 / ui-zoo-web

WASM backend using browser-native DOM:

  • 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 - Document-level listeners dispatch via data-rid attributes
  • 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: Scope::new() and Scope::run() exist in the codebase but are currently placeholders. The active scope mechanism is RenderScope, which tracks effects and child scopes for automatic cleanup.

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