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. A gpu build carries both, picks at run time, and falls back to software when the GPU will not start.
  • 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() {
    App::new(app).title("Counter").size(800, 600).run();
}

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

  • Stable Rust, edition 2024. Rinch itself is built and tested with the one version its rust-toolchain.toml names (1.99.0 today); no nightly feature is needed.
  • 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" — adds GPU rendering via Vello/wgpu beside the software renderer; the window uses the GPU and falls back to software when it will not start (see Rendering Backends). 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() {
    App::new(app)
        .title("My App")
        .size(800, 600)
        .theme(ThemeProviderProps {
            primary_color: Some("cyan".into()),
            ..Default::default()
        })
        .run();
}

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. App::new(app)…run() opened a window, loaded theme CSS, mounted the component, and started the event loop. App is the one entry point: every startup option — title, size, theme, menu bar, window props, GPU device — is a method on it, and they all compose.

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

Boolean attributes

A handful of HTML attributes carry no useful string: their presence is their value. disabled, checked, readonly, required, multiple, hidden, selected, open, autofocus and the rest of the HTML boolean set are all of this kind, and so are rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop and data-scroll-lock-exempt.

Write them as a bool — static or reactive — and rsx! renders the right shape rather than the string:

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

rsx! {
    // true  → `disabled` (present, no value)
    // false → the attribute is removed
    button { disabled: {move || busy.get()}, "Rename" }
}
}

This matters because HTML has no falsey spelling: a present disabled attribute disables the control whatever it holds, so disabled="false" is still disabled. Writing the string would make the binding a one-way latch — the control could turn off and never back on (issue #551).

The rule is keyed on the attribute’s name, not on the value’s type, because several attributes that take a bool-looking value need the literal "false" kept:

#![allow(unused)]
fn main() {
rsx! {
    // `draggable` is an *enumerated* attribute: "true" / "false" are both
    // values, and removing it means `auto`. It keeps the string.
    div { draggable: {move || on.get()} }
    // So do the ARIA states, and rinch's own `data-viewport-ready` — an
    // opt-out whose *absence* means ready.
    div { aria-expanded: {move || open.get()} }
}
}

A closure that yields a string into a boolean attribute follows a writer’s truthiness rule: everything is on except "false" (any case) and "0", so the bare "" that components write stays on.

That rule is the writer’s, and for the HTML set it is nobody’s reader: no reader of disabled, checked, readonly and their kind has a falsey string on either backend, because a browser has none (issue #612). rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop and data-scroll-lock-exempt — listed as boolean attributes above — are the exception, and the only one: their readers do honour a literal "false", on desktop through rinch_core::dom::data_attr_is_on and, for the ones the web reads at all, through selectors like [data-nofocus]:not([data-nofocus="false" i]). That escape is narrower than the writer’s rule, too: "0" is on for those five.

Writing one by hand. set_attribute is the literal primitive on both backends: it writes the string you give it, so set_attribute("checked", "false") leaves the attribute present, and a present boolean attribute is true. There are two spellings of off, and neither is a string:

#![allow(unused)]
fn main() {
// Rendering a bool you already have — presence or absence, chosen for you.
node.write_attribute("checked", &flag.to_string());

// Or say it outright.
if flag { node.set_attribute("checked", "") } else { node.remove_attribute("checked") }
}

That was a real divergence until issue #622: the web backend mapped truthiness onto presence for checked and selected alone, so the same call unchecked a box in the browser and checked it on the desktop backend.

On the web, checked, <option selected> and a media element’s muted also drive the control’s live property, not only the attribute — a browser stops mirroring checked/selected’s attribute into the property once the user has toggled the control (and never mirrors muted’s dynamically at all; HTML reads it once, at load, to seed defaultMuted), and rinch has no such flag, so a programmatic write keeps winning. checked/selected follow the attribute’s presence, matching what the desktop backend’s :checked reads; muted is rinch’s own invention — deliberately more dynamic than plain HTML — because nothing else lets a reactive muted: {|| m.get()} binding mute or unmute a <video>/<audio> element at all (issue #754). The name is folded ASCII-lowercase before any of this is decided, so CHECKED/MUTED written in any case still mirror (issue #758) — desktop already folds the attribute name this way (#738); on the web the browser folds the written attribute itself, so only rinch’s own Rust-side matching needed the fold.

These three are also the reason a falsey write of them is never skipped. Turning a boolean attribute off is otherwise a no-op when the attribute is already gone, and write_attribute skips it to save a restyle — but on the web a user’s toggle (or, for muted, a <video controls>’s own mute button) leaves the property on with the attribute absent, so the skip was exactly the write that would have corrected it, and a box the user clicked stayed checked against a binding that said false (issue #687, and #754 for muted). checked, selected and muted now always reach the backend, which is the only layer that can see the property.

Component props are unaffected — a component’s disabled: bool is an ordinary typed field, and the component decides how to render it.

Event Handlers

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

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

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

Supported HTML-element event attributes:

AttributeFiresClosure
onclickPrimary press (dispatched on pointerdown), or Enter/Space on the focused element — see Keyboard activation. A right or middle press does not run it, on either backend (both did until issue #1093); use oncontextmenu, or onmousedown with get_click_context().button. An overlay backdrop marked data-backdrop (rinch_core::events::BACKDROP_ATTRIBUTE) is the exception and runs for every button, so a press of any button outside an overlay dismisses itFn()
onmousedown / onmouseupPointer press / release (any button)Fn()
onmousemovePointer moves over the element — at most once per event-loop batch on desktop, at the newest position (see Pointer moves are coalesced)Fn()
onmouseenter / onmouseleavePointer enters / leaves the elementFn()
oncontextmenuRight-click, or a 500ms long press on Android (suppresses the native menu when handled)Fn()
oninput<input>/<textarea> value change, per keystrokeFn(String)
onchangeCommit boundary: the gesture ends (blur after a modification, Enter, a <select> pick) — fires with the final valueFn(String)
onscrollScroll container scrolls, on either axisFn(ScrollEvent) — ev.scroll_top and ev.scroll_left
ondragstart … ondrop, ondragendElement drag-and-drop. A cancelled drag (Escape; on desktop also a left press or a window blur while it is live, which prove its release went missing) fires ondragend (and ondragleave on the target, if it is over one), never ondropFn()
onfiledrop, onfiledragenter/onfiledragleaveOS → app file dropFn(Vec<PathBuf>) / Fn()

Pointer moves are coalesced

On desktop, winit can report several pointer moves between two frames — a 1000 Hz mouse does. The shell keeps only the newest one and hands it to the app before the next other event (so a press is judged where the pointer last was) or at the end of the batch. Hover, onmousemove, data-ondragover / data-ondragmove and a Drag::absolute() / Drag::percent() on_move all see one move per batch, at the latest position. This is the browser’s model: it dispatches pointermove at most once per frame. A component drag’s layout runs once per frame too, not once per move.

One pointer-capture drag at a time

Drag::start() ends any drag that is already live before arming its own, and it ends it through that drag’s on_cancel — never its on_end. Usually a press arming a drag while another is live means the earlier drag’s release was lost (a context menu or a window-manager grab swallowed it, or it went to another rinch document on the same thread — a DevTools window, a second embedded RinchContext), so there is no trustworthy position to commit. On a touch screen it can also be a second finger: rinch-web dispatches a second finger’s press, and a drag it arms cancels the first finger’s while that finger is still down — there is one drag at a time, not one per finger. Keep teardown in on_cancel and it runs on every ending but a commit — and an unmount of the component that armed the drag, which drops it without calling back.

A drag whose release never arrives is ended the same way, through on_cancel at the last position on_move saw. On rinch-web that happens on the first move that reports the button up. Desktop cannot see the button on a move, so it ends the drag on the next event that proves the release was missed: a left press (handled before the press reaches any handler, so nothing it dispatches sees the old drag) or the window losing focus. Until one of those arrives, the stranded drag keeps following the pointer. The same press or blur also ends a stranded element drag (cancelled: ondragend, no ondrop), a scrollbar-thumb drag and a text-selection drag.

Three consequences on desktop. A window blur mid-drag cancels the drag even when its release would still have arrived (on X11 and Wayland a global hotkey or a held Alt+Tab can blur the window during a healthy drag). A drag armed by the middle or right button is cancelled by a left press made while it is held. And a drag armed with no button down — from a shortcut or a timer, then placed with a click — is cancelled by that click, as the web cancels it on its first move. Arm a Drag from a press (onclick or onmousedown) and none of this reaches a normal gesture.

A drag ends on the release of the button that started it. On desktop a right or middle release while the left button still holds a drag is a chord, not the end of it: a left-armed Drag keeps following the pointer and commits on the left release, and the same holds for an element drag (it is not dropped), a scrollbar-thumb drag and a text-selection drag. A drag armed by a right press ends on the right release, and a drag armed outside any press (a timer) belongs to whichever button was pressed last. The web gets this from the browser, whose pointerup fires only once every button is up.

onscroll fires once per container that moved, whichever axis moved it, and its ScrollEvent payload carries both offsets — so a horizontal-only scroller reports its position rather than an unchanging scroll_top (issue #177). ScrollEvent is #[non_exhaustive]: read its fields by name, and construct one (in a test, say) with ScrollEvent::new(top, left) rather than a struct literal.

#![allow(unused)]
fn main() {
div {
    style: "overflow: auto; width: 200px; height: 100px",
    onscroll: move |ev: ScrollEvent| {
        column.set((ev.scroll_left / char_width) as usize);
    },
    // …wide content…
}
}

On desktop a scroll offset is kept on the device pixel grid (issue #1543), so scrolled text moves by whole device pixels and does not re-space as it scrolls: at a display scale of 125% the offsets are multiples of 0.8px. The ScrollEvent offsets and scroll_top() report that snapped value, so set_scroll_top(10.33) at 125% reads back 9.6. Do not compare an offset you wrote with one you read back for equality.

oninput and onchange follow HTML semantics and are not aliases (they were before issue #226): oninput fires on every keystroke with the live value, while onchange fires once when the typed gesture ends — focus leaves the control after a modification, Enter commits explicitly, or a <select> commits a pick — and only if the value actually changed since the control was focused. Use oninput for live previews and controlled inputs, onchange for validate-on-commit, autosave-on-leave, and undo bracketing. On Enter, onchange fires before onsubmit, and the eventual blur does not re-fire an already-committed value. Enter commits single-line inputs only — in a <textarea> (as in HTML) the gesture runs until blur, where Enter inserts a line break instead (see Enter in a <textarea>). Like the browser’s change, the event bubbles: data-onchange on an ancestor of the control receives the commit too.

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

The element bounds are the box the element is painted in, which is what getBoundingClientRect() reports on the web — so a CSS transform on the element or any ancestor moves and resizes them, and a position: fixed element reports its viewport box rather than one displaced by its ancestors (#203). That is what makes Drag::percent() and percent_x() read correctly inside the left: 50%; top: 50%; transform: translate(-50%, -50%) centring idiom, and inside a transform: scale() zoom container. mouse_x/mouse_y are unchanged — still plain viewport coordinates, which is what popup placement in DropdownMenu, Select and ContextMenu reads them as. For a rotated element the bounds are the axis-aligned bounding box, the same approximation the browser makes.

Keyboard activation

Enter and Space on a keyboard-focused element run its onclick — the same handler as a pointer press, on both backends. What Tab can reach differs by backend: on web the browser makes <button>, <a href> and the like Tab stops on its own; on desktop only an element with tabindex (or a text input) is a Tab stop today, so a bare <button> needs tabindex="0" there (issue #252). The handler receives a ClickContext whose cursor sits at the element’s centre (mouse_x/mouse_y = the middle of element_x/element_y

  • size), with button: Left and no text hit, so placement logic that reads get_click_context() (a Select flipping to fit the viewport, say) keeps working for a keyboard user. Those bounds are the painted box, the same one a pointer press reports, so a transformed element activates to the same numbers either way in. Ctrl/Meta chords are left alone; Shift and Alt ride along in modifiers.

On desktop, activation latches once per physical press (a held key does not repeat). On web, two routes cover the two kinds of element:

  • Natively activatable elements — <button> and <summary> on Enter or Space, <a href> on Enter, checkbox/radio inputs on Space — use the browser’s own activation: the click the browser synthesises is what dispatches the handler. The browser’s semantics apply, so <a href> also navigates on Enter (exactly as a mouse click on it already does), a checkbox still toggles, and a held Enter repeats at the browser’s key-repeat rate. Where the browser has no activation for a key — Space on a link — rinch dispatches it from keydown instead (the handler runs, the page does not scroll, and nothing navigates), matching desktop.
  • tabindex elements (a <div tabindex="0"> such as a Tree node) get no browser click, so rinch dispatches from keydown: once per press, and the key is consumed for the whole press — including its auto-repeats — so a held Space neither re-activates nor scrolls the page. Elements the browser focuses on its own without tabindex (a keyboard-focusable scroll container, <video controls>) keep their keys: Space there scrolls or plays, it does not activate the clickable around them.

Neither route double-fires with the mouse: a pointer press dispatches from pointerdown, and the trailing click of that same gesture — including the extra click a <label> fires at its control — is suppressed, even if a key was pressed while the button was held. A press that dispatched nothing has nothing to duplicate, so its clicks are not suppressed: a <label for="x"> outside every handler, whose <input id="x"> sits inside a clickable wrapper, activates that wrapper once by mouse as it does by keyboard (issue #272, web only — the desktop backend does not follow for, so there the press does nothing). The same rule lets a click through when something else swallowed the press: a third-party widget (a map, a chart) inside a clickable card that stops its own pointerdown from propagating now activates the card on release, as a browser’s own click would — stop click too if the widget must not. Clicks with no pointer gesture behind them — assistive technology (whose clicks are trusted, with no pointer or key event of their own on Firefox/WebKit), element.click() — are honoured once, and a click a handler raises itself (hidden_input.click()) does not re-enter that handler. Enter inside an <input>/<textarea>, an editable region or the rich-text editor is never an activation of a surrounding clickable; it is the onsubmit gesture described above (or, in a <textarea> with no onsubmit, a line break). An element with no live handler in its ancestry is a quiet no-op — the key falls through to the browser, so Tab and scrolling keep their usual meaning.

A mouse press focuses the nearest focusable ancestor of what it hits, like a browser — so a clicked tabindex element owns Enter/Space straight away, without having to be reached by Tab first. A component that wants the keys for itself (a code editor, a keyboard-driven grid) registers for them; see Keyboard Focus.

Enter in a <textarea>

Enter has two meanings in a multi-line field, and which one it takes is the author’s declaration:

FieldKeyWhat happens
<textarea>Enter, no onsubmitinserts a line break
<textarea>Enter, with onsubmitruns onsubmit — nothing is inserted
<textarea>Shift+Enteralways inserts a line break
<input>Enter / Shift+Entercommits (onchange, then onsubmit); never inserts

So a notes field takes line breaks with no ceremony, a chat composer sends on Enter and takes a break on Shift+Enter, and a single-line <input> is unchanged — a line break is not representable in its value.

onsubmit is read the way onchange is: from the control, then up its ancestors, so an onsubmit on the wrapper around a field submits that field. One backend difference to know about: on web, Shift+Enter in an <input> does nothing (the browser’s own Enter path is what desktop’s commit stands in for), while on desktop it commits like a plain Enter.

The break is an ordinary edit: it moves the caret, fires oninput with the new value (\n included) and is one Backspace away from gone. On Android the soft keyboard is told per focused field which kind it is serving, so a <textarea> gets a keyboard whose Enter types a newline instead of an action key that ends the input session.

Moving between lines

ArrowUp and ArrowDown move the caret one visual line in a <textarea> — a line the text soft-wrapped onto counts, as in a browser — aiming at the column the first of a run of vertical moves started from, so a short line in between does not pull the caret left for good. On desktop this reads the same text layout a click is hit-tested against, so the caret lands where a click at that height would put it (except at a soft-wrap point, below). ArrowUp on the first line goes to the start of the text and ArrowDown on the last line to its end; in a single-line <input> that is all they do (Chrome’s behaviour on Linux and Windows). Shift extends the selection. Without Shift a selection collapses, moving from its start (up) or its end (down). An <input type="number"> is not moved: a browser steps its value there, which desktop does not do.

Where a soft-wrapped line is shorter than the column aimed at, desktop stops one character before the wrap point, since a caret at the wrap point itself would be drawn at the start of the next line. A browser keeps a caret affinity for that and places it after the last character instead. Where the wrap is at a space the space hangs and nothing looks different; in text with no spaces (CJK) the caret is visibly one column short.

Home and End in a <textarea> go to the start and end of the caret’s visual line, soft-wrapped lines included; Ctrl+Home and Ctrl+End (Cmd on macOS) go to the start and end of the whole text. Shift extends the selection. Without Shift a selection collapses and moves from its head — the end the caret is at — rather than from its start or end as ArrowUp and ArrowDown do, which is what Chrome does. In a single-line <input> Home and End go to the ends of the value. A Home or End moves the caret off the column a run of vertical moves was aiming at, so the next ArrowUp or ArrowDown aims from where the caret now is; one that leaves the caret where it was keeps that column, as in Chrome.

End on a soft-wrapped line follows the rule above: desktop stops one character before the wrap point, a browser at the wrap point with the caret drawn at the end of the line. When the wrap is at a space, the caret sits before that space, so a character typed there goes before it — abcd abcdX abcd where Chrome types abcd abcd Xabcd.

Sizing a <textarea>

A <textarea> holds its value in its value attribute, and has no content to size against. Text written as its child — textarea { "hi" }, or a parsed <textarea>hi</textarea> — is its default value, as in HTML: the field shows and edits it, and follows it when it changes (a reactive textarea { {|| draft.get()} }) — until the user edits the field or something sets its value. That is HTML’s dirty value flag, which focusing and blurring do not set (#1186); from then on a child change updates only the default value (defaultValue on the web), and the field keeps what the user typed. The rule is the same on the desktop and on rinch-web, value writes included (#1222): a write equal to the shown text sets the flag too, as a script .value write does in Chrome, and removing the value attribute is a write of "" — the field empties and stays dirty, since no script write clears the flag again. (A browser’s form reset does; the desktop has no form reset.) A child change while the field is focused and unedited keeps the caret at its offset, clamped to the new text, as in Chrome.

A text child is not a controlled value. Until #1206, rinch-web wrote a textarea’s value from its children on every change (#100), so textarea { {|| draft.get()} } with draft.set(String::new()) after a submit cleared the field there even after the user had typed — and did not on the desktop. It no longer does on either backend. For a field the app keeps in step with a signal, bind the value itself — a value_fn on the Textarea component, or a reactive value: on the raw element — which reaches the field whether or not the user has typed:

#![allow(unused)]
fn main() {
let draft = Signal::new(String::new());
rsx! {
    div {
        Textarea {
            value_fn: move || draft.get(),
            oninput: move |v: String| draft.set(v),
        }
        // or, on the raw element:
        // textarea { value: {|| draft.get()}, oninput: move |v: String| draft.set(v) }
        button { onclick: move || { send(draft.get()); draft.set(String::new()); }, "Send" }
    }
}
}

It sizes the field no more than a value does. An <input>’s children are not shown at all, and neither is an element child of a <textarea> — a div built into one through rsx or append_child has no box, draws nothing and does not size the field (#1178), as in a browser. A control with no width is as wide as its cols (a textarea, default 20) or size (a text <input>, default 20) average characters of its font, as in a browser, and a textarea reserves a scrollbar’s width beside them (#1177). A textarea’s height comes from rows, which reserves that many lines plus padding and border — 2 rows when unset, matching HTML. rows is read as HTML reads it: the leading digits ("3abc" is 3, "2.5" is 2), and 2 when there are none or they come to zero:

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

min-height, max-height and an explicit height all work as usual: height overrides the rows height outright, min-height acts as a floor so the taller of the two wins, and max-height caps it. The rows height is the textarea’s content height, as in a browser, so a min-height: 0 textarea in a flex column can shrink below it. An <input> is sized the same way from one line. The Textarea component exposes the same thing as min_rows.

Components

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

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

    Alert { icon: TablerIcon::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.

Attributes on a component

A component’s props are the fields of its struct, so Group { gap: "xs" } sets a field. A hyphenated name can never be a Rust field, so rsx! treats one written on a component as an attribute for the component’s root DOM element, applied after Component::render — the same route the universal style: and class: props take:

#![allow(unused)]
fn main() {
rsx! {
    // The whole toolbar takes clicks without taking the keyboard; no wrapper div.
    Group { gap: "xs", data-nofocus: "", aria-label: "Formatting",
        Button { onclick: move || ed.command("toggleBold"), "B" }
    }
}
}

That covers data-*, aria-* and any other hyphenated name (issue #433). The rules are the HTML element’s rules:

  • A boolean attribute — the HTML set and rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop and data-scroll-lock-exempt — is written as a presence for a truthy value and removed for a falsey one (Boolean attributes). Any other attribute is written as the string.
  • A reactive value, data-state: {|| state.get()}, is an effect on the root that rewrites the attribute when its signals change, and re-renders nothing — on a component that re-renders for a reactive struct prop too (issue #1190): the effect belongs to the current render, so a change to the attribute’s signals updates the root in place and the component keeps its local state. When the component re-renders for its struct prop, every attribute is written again on the new root, and a reactive one gets a fresh effect there. The same holds for style:, class: and the style shorthands.
  • The caller writes last. An attribute the component also writes on its own root takes the caller’s value at mount, and a falsey boolean removes the component’s. After that the last write wins: a component effect that later writes the same attribute overwrites the caller’s value, and a reactive attribute overwrites it back only when its own signals change. That includes rinch’s own wiring attributes: a caller’s data-rid on a component replaces the id of the component’s own click handler, so it stops firing. Don’t.

Unhyphenated attribute names — id, tabindex, role — are not routed: on a component they are still struct fields, and a component that does not declare one does not compile.

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

Showing a subtree you built earlier

A branch body does not have to build fresh markup. It may yield a NodeHandle you built once outside the if, and toggling the condition then moves that same subtree in and out of the page rather than rebuilding it:

#![allow(unused)]
fn main() {
let panel = heavy_panel(__scope);   // built once
let open = Signal::new(false);

rsx! {
    div {
        if open.get() {
            {panel.clone()}
        }
    }
}
}

The subtree keeps its identity across every toggle: its DOM nodes, any state inside them, and anything else holding a handle into it all survive being hidden. That is guaranteed on both backends — issue #719 was a period during which rinch-web lost the subtree on the first hide, silently, while desktop kept it.

The subtree is kept wherever it sits in the branch’s markup, including nested inside something the branch builds itself — if open.get() { div { {panel.clone()} } } — which used to throw it away with the wrapper (issue #732). The wrapper div is the branch’s own and is rebuilt on every show and discarded on every hide as usual; panel is detached rather than discarded along with it, and the next show puts the same panel back inside a fresh wrapper.

What counts as “built by the branch” is anything minted through the branch’s RenderScope or through a scope created on its behalf — a for’s rows, a nested if, a component, a List patching a late row — and anything minted by raw document access under such a node (an Editor’s blocks, raw HTML). If your own code builds nodes through a separate RenderScope that belong to the branch and go with it (an observer patching rows, a timer adding to the branch’s markup), create it with RenderScope::with_parent(doc, node, Some(scope.id())), naming the branch’s scope. Content that must outlive the branch — a cache you show again later — must not name it: build it with RenderScope::new, or outside the branch, and it is only ever detached. A RenderScope::new placed inside a branch’s markup belongs to no branch, so a hide only detaches its nodes; if nothing shows them again they are never reclaimed.

A cache filled from inside a for

An if can be handed a panel that was built before it. A for row usually cannot: the row’s subtree depends on the item, so a cache of rows is filled inside the row’s body, the first time each key is seen. Everything built through the scope the body is given belongs to the row — its nodes are discarded when the key leaves the list, and its effects, signals and event handlers are disposed — so a row cached that way is gone the next time its key appears (issue #733). On rinch-web nothing is shown for it; on desktop the nodes come back and their reactive bindings do not.

Build a cached row through a cache scope instead, and keep the scope with the row:

#![allow(unused)]
fn main() {
type TabCache = Rc<RefCell<HashMap<u32, (NodeHandle, RenderScope)>>>;

/// Hand back the cached tab, or build it once and keep its scope with it.
fn cached_tab(scope: &mut RenderScope, cache: &TabCache, id: u32) -> NodeHandle {
    if let Some((tab, _keep)) = cache.borrow().get(&id) {
        return tab.clone();
    }
    let mut keep = scope.cache_scope();
    let tab = keep.build(|__scope| rsx! {
        article { {expensive_tab(__scope, id)} }
    });
    cache.borrow_mut().insert(id, (tab.clone(), keep));
    tab
}

#[component]
fn tab_strip(open_tabs: Signal<Vec<u32>>) -> NodeHandle {
    let cache = TabCache::default();
    // Let go of every cached tab when the strip unmounts.
    let evict = cache.clone();
    __scope.on_cleanup(move || {
        for (_, (tab, keep)) in evict.borrow_mut().drain() {
            keep.dispose();
            tab.discard();
        }
    });
    rsx! {
        div {
            for id in open_tabs.get() {
                {cached_tab(__scope, &cache, id)}
            }
        }
    }
}
}

(This block is compiled and run as crates/rinch-macros/tests/rsx_for_cache_scope_733.rs.)

  • scope.cache_scope() returns a new RenderScope on the same document that belongs to no row and no branch. Nothing the list does discards what it builds.

  • keep.build(|__scope| …) builds through it and makes it the owner of every Signal::new, Memo::new, store and event handler created inside. Do not skip build and use the cache scope’s &mut directly: the row then comes back with its nodes, but a Signal::new, a memo or an onclick made while building belonged to the row that happened to be rendering and was freed with it. A write to that signal is then a no-op, the bindings that read it never update, and nothing panics or warns.

  • The scope and the node are released together, by you. They are two values, and nothing ties them:

    • drop the scope and keep the node, and the node comes back inert — its effects, signals and handlers were disposed;
    • discard the node and keep the scope, and its effects go on running against a node that no longer exists, for as long as the scope lives;
    • drop both without discarding the node — which is what happens when the cache itself is dropped, because the component that owns it unmounted — and the effects stop but every cached node stays in the backend for the life of the page. Measured on the mock and in Chrome 153: 50 mounts of a list of two cached rows leave 200 nodes behind without the on_cleanup above, and none with it.

    So evicting one entry is scope.dispose() and then node.discard(), and a cache owned by anything that can unmount drains itself in on_cleanup, as tab_strip does. A key that never comes back holds its row until then. (One value that owns both and releases them in step is issue #1454.)

  • A cached row is not rebuilt when its item changes. The body is called again for a changed item under the same key and hands the same node back, so anything in it that should follow the item has to be reactive.

The same works in an if or match body whose cache is filled lazily.

What tells you when a cached row was built the wrong way. If a body’s root node — the node the body itself evaluates to — was already discarded, rinch counts it and emits a tracing warning that names cache_scope, once per node and for at most 32 nodes per thread. That is narrower than it sounds:

  • It is a tracing event, and nothing shows it unless a subscriber is installed. The desktop shell installs one; rinch-web installs none (issue #1455), so a browser app sees it only with a console subscriber of its own (the tracing-wasm crate is one), and cargo test prints it only if the test installs one (tracing_subscriber::fmt().with_test_writer()). What a test can assert on without one is the counter, rinch_core::dom::__retired_view_returns() — (returns seen, nodes warned about) on the current thread.
  • Only a backend that reclaims discarded nodes can notice: rinch-web, and the MockDomDocument your component tests run on. Desktop reclaims nothing yet, so it shows the row and reports nothing.
  • A discarded node nested in fresh markup is not noticed. for t in tabs.get() { div { class: "tab", {cached} } } with cached built through the row’s scope loses the row in the same way — the div comes back empty — and nothing is counted or logged, because the body’s root is the fresh div (issue #1456).

So the absence of a warning proves nothing: test a cached list on the mock or in a browser, not only on desktop, and assert that the row is there.

Two more things to know. The branch’s RenderScope is disposed on every hide, so effects created inside the branch closure stop; put the reactive wiring in the same scope as the panel binding, not in the closure. And a subtree that is out of the page is not laid out, so a measurement taken while it is hidden is stale.

Plain markup in a branch — if open.get() { p { "hi" } } — is rebuilt on every show and thrown away on every hide, which is what you want for anything cheap.

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

Braces around control flow

A brace in child position is transparent to control flow: { match x { … } } renders the same reactive match_dom as match x { … }, and the same goes for if and for, at any depth — including a braced match arm body, _ => { if … }.

That is worth stating because the two used to differ, silently. A braced construct was parsed as a plain Rust expression and evaluated once, so it kept whichever branch was true when it mounted and never changed again — one brace apart from a fully reactive twin, with no diagnostic (issue #221).

Braced control flow whose bodies are not rsx cannot be made reactive — there is no markup for the runtime to swap — so it is a compile error now instead of a silent freeze:

#![allow(unused)]
fn main() {
// error: `match` wrapped in braces renders once and never updates
{ match selected.get() {
    Some(p) => rsx! { div { {p.name.clone()} } },
    None => rsx! { div { "—" } },
} }
}

The message names both ways out, because which one is right depends on what you meant. For reactive markup, drop the braces and write each arm as rsx rather than as a nested rsx! invocation:

#![allow(unused)]
fn main() {
match selected.get() {
    Some(p) => div { {p.name.clone()} },
    None => div { "—" },
}
}

For a reactive value, wrap the whole thing in a closure — the reactive expression form from earlier in this guide:

#![allow(unused)]
fn main() {
{|| match selected.get() {
    Some(p) => p.name.clone(),
    None => "—".to_string(),
}}
}

Everything else in braces is untouched: an expression ({ count.to_string() }), a reactive closure ({|| count.get()}) and a call ({ section(__scope) }) never begin with a control-flow keyword, so they are the expressions they always were.

Capturing the same value in more than one branch

Every closure RSX builds is a move closure — a branch, a match arm, a per-item view, a reactive binding’s effect, an event handler — and show_dom takes the then branch and the else branch, so both are constructed whichever way the condition comes out. A non-Copy value named by two of them would be moved twice.

The macro handles that for you. At each construction site it emits a shadow clone of what that site shares with a sibling site, or reaches for from outside a body that re-runs. So this compiles, and label is still yours afterwards:

#![allow(unused)]
fn main() {
let label = String::from("hello");

rsx! {
    div {
        if label.is_empty() {
            p { "empty" }
        } else {
            p { {label.clone()} }
        }
        Text { {label.clone()} }
    }
}
}

What is not cloned. A value only one site names is moved, exactly as before, so a type that isn’t Clone keeps working where it always did. A value bound inside a repeating body — a let at the top of a branch, the for item itself — is a fresh local on every run and is never cloned. Signal and Memo are Copy, so where a shadow does fall on one it is a copy, not an allocation.

What still needs a manual .clone(). The analysis is lexical: it cannot see a value hidden inside a macro (format!("{}", label) hides label from it), and it compares sites within one construct — two separate if blocks that both name one String still need one of them to clone. When rustc does report an E0382 or E0507 from inside rsx!, the fix is the ordinary one: give the site that complains its own clone.

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

Keys must be unique within one list. The whole reconcile rests on one key naming one item and one DOM node, so a repeat used to leave a row that rendered, swallowed clicks and never updated again (issue #185). What a repeat means depends on who chose the key:

Rule
You wrote key:A repeat is a mistake in your key. The repeat is not rendered and a warning is logged — the first occurrence wins, the same rule React applies to duplicate key props.
No key:The framework fabricated the key from format!("{:?}", item), so a repeated value is not your mistake. Rinch makes the fabricated key unique by its occurrence ordinal instead: every row renders.

So for tag in ["rust", "rust", "gui"] renders three rows, and for n in vec![1, 1, 2] renders three rows. Reordering such a list still moves rows rather than rebuilding them, because the ordinal follows the value, not the position.

The one thing the fallback cannot give you is stable identity across an edit: if the first "rust" is deleted, the second one inherits its key and its DOM node. Where rows carry per-row state, give them a real key::

#![allow(unused)]
fn main() {
for (i, n) in numbers.get().into_iter().enumerate() {
    div { key: i, {n.to_string()} }
}
}

Prefer a stable per-row id where the data has one. An index key makes identity follow position, so inserting or removing anywhere but the end re-renders every row after the change — correct, but it gives up the reconciliation the keys are there for. Reach for the index only when the rows genuinely have nothing else to distinguish them.

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

A binding counts as used if either the key: expression or the body reads it, so an item used only as a key (for id in ids { span { key: id, "row" } }) does not warn unused variable, and neither does an if let or match binding its branch reads. A binding nothing reads still warns, at your pattern. (For a for loop the check is by name, so a binding that both the key and the body merely spell — as a path segment or a struct-literal field name, say — can go unreported.)

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

Several nodes in one arm

An arm body in braces can hold several nodes, as an if or for body can — they are rendered together as the arm:

#![allow(unused)]
fn main() {
match tab.get() {
    0 => { h2 { "Home" } p { "Welcome back." } },
    1 => { let n = count.get(); b { {n.to_string()} } " items" },
    2 => { Badge { "new" } Badge { "beta" } },
    _ => { overview_section(__scope) },   // one expression, as always
}
}

What the braces hold is decided by how they start:

  • let always starts rsx nodes.
  • An element or component (div {, Card {), a string literal, if/for/ match, or an interpolation { … } starts rsx nodes when more nodes follow it: { {label} span { "!" } }, { if open.get() { "▾" } span { "Menu" } }.
  • An element, component or string literal on its own is that one node.
  • Anything else is a single Rust expression, as it always was: a call, a variable, a closure, and a method called on a literal or on a struct ({ "a".to_string() }, { if a { "x" } else { "y" } .len() }). So { overview_section(__scope) }, { panel.clone() } and {|| count.get().to_string()} are unchanged. Lone control flow in braces is the transparent brace described under Braces around control flow.

(Until issue #395 only a body starting with let could hold more than one node.) A mistake inside a multi-node arm is reported by the rsx parser, at the mistake, like one in any other rsx body — including one inside a leading if/for/match when another node follows it. (Finding that following node after a head that failed to parse is a token-level heuristic: a condition that itself holds braces, such as if let Foo { a } = x, can defeat it and leave you with the single-node error instead.) A lone braced if/for/match whose bodies are not rsx still gets the “renders once” error described under Braces around control flow.

One consequence: { Point { x: 1 } } in an arm is a component named Point, exactly as Point { x: 1 } unbraced is, not a Rust struct literal. A struct literal that cannot be an element — shorthand fields ({ Foo { a } }), a .. base ({ Foo { ..Default::default() } }), or one with a method called on it ({ Point { x: 1 }.into_node(__scope) }) — stays an expression, and so does a path such as geom::Point { x: 1 }, which is not an element name.

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}). On a positioned element the top/right/bottom/left shorthands accept any CSS length — px, %, em, var(), calc() — not just the spacing scale. A calc() mixing % with a length (calc(50% - 10px)) resolves correctly on desktop too (#278) — for insets, sizes, margins, padding, gaps, transforms and border radii alike. One named limitation: a non-affine calc() (a min()/max()/clamp() with a percentage inside) resolves as its large-container linearization on desktop rather than tracking the breakpoint.

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

style: is merged, not assigned (issue #647). Its declarations are laid over whatever is already on the element — a shorthand prop, something the runtime wrote through set_style(), and on a component every inline declaration the component’s own render wrote — last-wins per property:

#![allow(unused)]
fn main() {
// The component publishes `--rinch-modal-z-index: 517` on its root from
// `render`; the caller's `margin: 0` joins it rather than replacing it.
Modal { z_index: 517, style: "margin: 0" }

// `padding` survives the first signal change. It used to be erased, because
// the re-firing `style:` effect wrote the whole `style` attribute.
div { style: {|| format!("color: {}", tint.get())}, p: "md" }
}

Shorthands are applied through set_style() after component rendering and after the style: prop, so a shorthand wins a collision with style: at mount:

#![allow(unused)]
fn main() {
div { style: "padding: 0", p: "12px" }   // padding is 12px
}

A reactive style: takes that back on its first re-fire, and keeps it:

#![allow(unused)]
fn main() {
let css = Signal::new(String::from("padding: 0"));
div { style: {move || css.get()}, p: "12px" }
// padding is 12px at mount, and 0 from the first `css.set(...)` onward
}

The shorthand is applied once, at mount; nothing re-asserts it. Declaring the same property from both props on one element is the mistake — pick one. Everything else composes:

#![allow(unused)]
fn main() {
let css = Signal::new(String::from("margin: 0"));
div { style: {move || css.get()}, mt: "8px" }   // margin-top stays 8px, always
}

That second line is the shape the merge has to get right, and it is not a collision: margin-top beats margin only while it stays after it in the block, so a re-run must overwrite the closure’s margin where it stands rather than removing and re-appending it. A reactive style: also never accumulates — a property the closure has stopped naming is removed, or restored to the value it had been overriding — and it leaves alone any property whose current value somebody else has written since, rather than reverting a value it never saw.

An element with no second author on its style attribute keeps the author’s string verbatim, on every fire.

One parser, and what it does with an awkward value

Every author of an inline style — a style: prop, a style shorthand, a set_style() from a component or the runtime — splits and re-joins the attribute with the same parser (issue #670). Four things follow that you can rely on:

  • A ; or : inside url(…), "…" or '…' is part of that value. A background-image: url(data:image/png;base64,…) already on the element survives every later write by anybody else. rinch-dom used to split on a bare ;, so the next unrelated set_style() on that node cut the URI in half and the image vanished.
  • A property name is ASCII case-insensitive, as CSS makes it (issue #711). style: "color: blue; COLOR: red" is one property declared twice, not two properties, and a later set_style("color", …) replaces it however either was spelled. A custom property is the exception and is compared exactly: --Foo and --foo are two variables, here and in a browser.
  • A property declared twice collapses at the last declaration’s position, which is what a browser does. That is behaviour and not spelling as soon as a shorthand is involved: style: "inset: 0px; left: 25px; inset: 4px" computes a left of 4px, because the surviving inset sits after the left it overrides.
  • …but the value that survives is the !important one, which is not always the last. style: "color: red !important; color: blue" stays red, as in a browser. The slot still moves to the last declaration’s position; only the value is decided by priority.

One caveat on the last two, and it only bites on invalid CSS (issue #722): rinch collapses duplicates before validity where a browser collapses after. A browser throws an invalid declaration away as it parses, so the duplicate never competes; rinch keeps it, and the property can end up with no usable value. style: "color: notacolor !important; color: blue" computes blue in a browser and falls back to black here. Two valid declarations — the only shape worth writing — behave identically.

What the attribute reads back as afterwards is the CSSOM-touched form: names lowercased, duplicates collapsed. A browser keeps the author’s bytes in getAttribute("style") until something touches the CSSOM and rinch re-serialises on every merge, so rinch reaches that form one write earlier — the two agree on the result, not on when it appears. An element nobody has written to since is untouched either way.

/* … */ comments are removed before any of this — from a property name as readily as a value — everywhere CSS sees one, and each leaves a space behind, because a comment ends the token before it even though it is not one itself. The two places CSS does not see a comment are inside a string and inside an unquoted url(…), which is a single token: url(http://a/*b*/c.png) keeps its /*b*/ and names the image the author wrote, as in a browser. There is one place a browser sees a comment and keeps it where rinch removes it — the interior of a custom property value, where Chrome reads --x: 1px /* c */ 2px back verbatim and rinch reads it back as 1px 2px. The token stream is the same either way, so a var() substitution cannot tell; only the attribute string differs. An !important is carried through the value untouched; it is read, never rewritten, and the reading honours CSS’s own looseness — red!important, red ! IMPORTANT and red !ImPoRtAnT are all important, while a !important inside a string or url(…), or one whose ! is escaped, is ordinary text.

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.

Lifetimes: What Owns Your State

State created during a render belongs to the thing being rendered, and dies with it. You rarely have to think about this — it is what stops a list that churns 10,000 rows from leaking 10,000 signals — but it becomes visible the moment a handle outlives its component.

The rule: a Signal, Memo, Effect or event handler created while something is rendering is owned by that render. When the owner goes away — a for row is reconciled out, an if branch flips, a component unmounts — every resource it owned is freed.

No ambient owner means app lifetime. A signal created in main(), in startup code, or in a detached callback has no owner and lives as long as the thread. That is why hoisted, app-wide state keeps working with no ceremony.

Reading a freed handle panics; writing to one does not

Signal<T> is Copy, so nothing stops a handle from outliving what created it — a background thread, a timer, a global registry. Reads and writes deliberately behave differently:

#![allow(unused)]
fn main() {
count.get();       // panics if the signal was freed
count.set(5);      // no-op plus a warning if the signal was freed
}

You may always write to a handle; you may only read a live one.

The asymmetry is not an oversight. A background thread that calls send() every 50ms cannot be asked to notice that the UI moved on, so a write to a dead signal must be survivable. A read has nothing to return — T need not implement Default — so it asserts.

When a handle may legitimately be gone, ask instead of assuming:

#![allow(unused)]
fn main() {
if let Some(n) = count.try_get() {       // None if freed
    println!("still here: {n}");
}
let len = items.try_with(|v| v.len());   // Option<usize> — None if freed
if count.is_alive() {                    // no subscription is created
    schedule_more_work();
}
}

Memo<T> has try_get() and is_alive() too. is_alive() never subscribes the current observer — liveness is not reactive, and tracking it would resurrect the dependency you are trying to drop.

Global callback registries are released too

A callback handed to a process-wide registry is the classic way for a component’s state to outlive the component — the registry keeps the closure, the closure keeps a Signal, and the next event reads a handle whose storage is gone. Registering one from inside a render therefore ties it to that render:

#![allow(unused)]
fn main() {
// `set_keyboard_interceptor` is not in the prelude — it is a document-level
// capture-phase hook, not everyday component API. `set_paste_interceptor` is.
use rinch::core::set_keyboard_interceptor;

#[component]
fn shortcuts() -> NodeHandle {
    let count = Signal::new(0);
    // Released when this component unmounts — the interceptor cannot outlive
    // `count` and read it after it is freed.
    set_keyboard_interceptor(move |k| {
        // Releases arrive here too (`k.is_up()`) — gate on the press or a
        // held "j" counts twice.
        if k.is_down() && k.key == "j" { count.update(|n| *n += 1); true } else { false }
    });
    rsx! { div { {|| count.get().to_string()} } }
}
}

This applies to set_keyboard_interceptor, set_paste_interceptor, set_selection_callback and set_selection_sync_callback (issue #183). Two consequences are worth knowing:

  • Registering with no ambient owner still means app lifetime. A hook installed from main(), from startup code or from a detached callback has no owner, so nothing removes it — unchanged, and what app-wide shortcuts rely on.
  • An earlier component unmounting never clears a later one’s registration. These are single, last-wins slots — per document, since two documents can share a thread (issues #340, #478): a registration made while a document’s code is running — its event dispatch, its mount, or an effect it owns (issue #295), or a timer, run_on_main_thread closure or http/ws callback armed from any of those (issue #963) — belongs to that document, one made outside any document (from main, a timer armed there, on rinch-web) is the thread-global fallback, and each document is served whichever of its own and the fallback is newer — so the last registration wins in a single-document app wherever it was made. A clear from outside any document clears every entry; one from inside a document leaves that document with nothing. With several documents on one thread that cuts both ways: code outside any document — main, a timer armed there, a closure queued from a worker thread, a native menu or tray callback — overrides every document’s own registration and its clear wipes them all. The release only reclaims the slot if it is still holding the callback that registered it.
  • Register once per component, not once per event. Each call from inside a live component queues its own release, and those accumulate until the component unmounts. Installing a hook from a render (as above) is the intended shape; re-installing one on every click or on every run of a reactive closure is not.

rinch-ws callbacks work the same way from the outside — an on_message registered inside a component stops firing once that component unmounts, even if the WsHandle was parked somewhere longer-lived:

#![allow(unused)]
fn main() {
// `WsHandle` is neither `Clone` nor `Copy`, so a store holds it behind an `Rc`:
//     #[derive(Clone)] struct AppStore { socket: Rc<WsHandle> }
#[component]
fn feed() -> NodeHandle {
    let lines = Signal::new(Vec::<String>::new());
    // The socket outlives this component: the store holds the handle open.
    let ws = use_store::<AppStore>().socket;
    // Holds `lines`, which this component owns, so it must stop firing when
    // this component goes away.
    ws.on_message(move |m| {
        if let Some(t) = m.as_text() {
            lines.update(|v| v.push(t.to_string()));
        }
    });
    rsx! { div { {|| lines.get().join("\n")} } }
}
}

The mechanism underneath differs, and the difference is visible in one place. The interceptor slots above release the callback when the scope disposes; a WebSocket callback is checked at dispatch and dropped by the first event that arrives after the component is gone. That is deliberate: a socket’s callbacks can be re-registered as often as the app likes, and queueing a release per registration would grow without bound. The practical consequence is that the “register once per component” advice above does not apply to rinch-ws — and that a callback belonging to an unmounted component is released on the next event rather than at unmount.

Each of a connection’s four callbacks carries its own owner, so one handle may be shared by two components — a message list registering on_message, a connection-status badge registering on_close — and each stops on its own component’s unmount rather than on the other’s. A slot that will never be dispatched again (on_open after the handshake) is released by the first event that finds any of the connection’s callbacks dead.

Android platform callbacks

The rinch-android services follow the rinch-ws rule, for the same reason — they are registered from live components, often more than once:

#![allow(unused)]
fn main() {
use rinch_android::sensors::{DELAY_UI, SensorType};

#[component]
fn compass() -> NodeHandle {
    let heading = Signal::new([0.0f32; 3]);
    // Stops firing, and is released, when this component unmounts. Before, only
    // an explicit `stop()` removed it, and the callback kept reading `heading`
    // after it had been freed.
    rinch_android::sensors::start(SensorType::MagneticField, DELAY_UI, move |d| {
        heading.set([d.values[0], d.values[1], d.values[2]]);
    });
    rsx! { div { {|| format!("{:?}", heading.get())} } }
}
}

This covers sensors::start, location::start, lifecycle::on_pause / on_resume, and the one-shot results behind camera::pick_image, camera::take_photo, file_picker::pick_file / save_file and permissions::request_permission. Four things follow:

  • The one-shots are covered too. They are removed on delivery, which bounds the leak but not the lifetime: Android delivers a result whatever the user does — a cancelled picker still reports RESULT_CANCELED — so a picker opened by a component that has since unmounted used to be delivered exactly once, into that component’s freed state. It is now discarded instead.
  • Release does not wait for an event. Every one of these registries is drained once a frame, and each drain also releases what unmounted components left behind. A sensor that has fallen silent, an activity result that never comes back and an app that never backgrounds would otherwise pin their callbacks — and everything captured — for the life of the process. Each release is logged at debug, because the symptom is otherwise silent: the callback simply stops firing.
  • Releasing a sensor or location callback powers the hardware down, because nothing else can. Once the entry is gone, sensors::stop has no SensorType to be called with and the component that knew it is disposed — so the release calls stopSensor / stopLocationUpdates itself, rather than freeing a Box and leaving a radio on. Only what it actually released: a sensor another component is still using stays armed, including one re-registered from inside the released callback’s own Drop.
  • Registration from android_main keeps app lifetime, unchanged. That is what an app-wide on_pause autosave relies on.
  • Stopping from inside the callback works. “Stop the sensor once the reading crosses a threshold”, or “stop location once the fix is accurate enough”, used to panic with a BorrowMutError, because the registry was borrowed across the call. So did swapping a lifecycle handler from inside one.

sensors::stop and location::stop remain the way to stop early — the moment you have the fix you wanted, rather than at unmount. What they no longer have to be is a leak-preventing ritual.

Keeping the screen on: a guard, not a callback

One Android service ties itself to the component by a third shape. screen::keep_screen_on() returns a guard — the display is held awake while the KeepScreenOn value is alive, and dropping it releases the hold (refcounted, so several holders compose). There is no callback to check at dispatch: a window flag is written once and never dispatched again, so nothing would ever visit it to notice a dead owner. The guard supplies the release structurally instead.

Most callers want the reactive form, which is also where unmount release comes from — the effect underneath is scope-owned, and disposal drops the closure holding the guard:

#![allow(unused)]
fn main() {
#[component]
fn player() -> NodeHandle {
    let playing = Signal::new(false);
    // Held while `playing` is true; released when it goes false — or when
    // this component unmounts mid-playback, whichever comes first.
    rinch_android::screen::keep_screen_on_while(move || playing.get());
    // ...
}
}

keep_screen_on_while(|| true) reads as “while this component is mounted”. The opt-out for a kiosk binary that genuinely never releases is keep_screen_on().leak(), from android_main.

Opting out

Sometimes a resource is meant to outlive the component that created it — state handed to a longer-lived store, a registration something else owns:

#![allow(unused)]
fn main() {
let s = Signal::new(0).leak();       // this one signal gets app lifetime
unowned(|| { /* nothing in here is owned by the current render */ });
}

leak() searches the owner stack as it stands now, so call it in the same render that created the signal; from a later callback the stack is empty and it does nothing. unowned is a free function at the crate root rather than in the prelude, deliberately: it is an attractive nuisance that converts a lifetime bug into a permanent leak. Reach for try_get() / is_alive() first.

Effects are the exception

Dropping an Effect handle does not stop the effect. Disposal is explicit (effect.dispose()) or comes from the owning scope. 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.

Reactivity

Rinch uses a fine-grained reactivity model inspired by Solid.js and Leptos. This means that when state changes, only the parts of your UI that depend on that state are updated—not the entire component tree.

Why Fine-Grained Reactivity?

Traditional virtual DOM approaches (like React) re-render entire component subtrees when state changes, then diff the virtual DOM to find what changed. This works well but has overhead.

Fine-grained reactivity tracks dependencies at a granular level. When a signal changes, only the specific effects subscribed to that signal re-run. There’s no diffing step—updates are direct.

Benefits:

  • Minimal re-computation
  • Predictable performance
  • No dependency arrays to maintain
  • Automatic subscription management

Core Primitives

Rinch provides three core reactive primitives, plus a store pattern for shared state:

PrimitivePurposeWhen to Use
SignalHolds reactive stateFor any mutable state
MemoCaches computed valuesFor derived/computed state
StoresShared state with actionsFor state used by multiple components
EffectRuns side-effectsAdvanced: syncing to external systems

Note: For DOM updates, use {|| expr} closures in rsx rather than Effect. For shared state, use stores. Effect is a power-user escape hatch for rare cases like syncing to external systems.

Quick Example

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

#[component]
fn counter() -> NodeHandle {
    // Create reactive state
    let count = Signal::new(0);

    // Create a derived value (Memo is Copy, just like Signal)
    let doubled = Memo::new(move || count.get() * 2);

    rsx! {
        div {
            // Use closure syntax {|| ...} for reactive updates
            // Both Signal and Memo are Copy — no .clone() needed
            p { "Count: " {|| count.get().to_string()} }
            p { "Doubled: " {|| doubled.get().to_string()} }

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

Note: The closure syntax {|| expr} is required for fine-grained reactive updates. Without it, values are captured once and never update. See RSX Syntax - Reactive Expressions for details.

When are closures unnecessary? Inside for loop bodies, components are re-created when the item data changes (via keyed reconciliation). Plain props from the loop variable don’t need closures — only per-item Signals do. See RSX Syntax - Reactivity in for Loops for details.

How Dependency Tracking Works

  1. When an Effect or Memo runs, it registers itself as the “current observer”
  2. When a Signal is read (via .get()), it checks for a current observer
  3. If there’s an observer, the signal subscribes it
  4. When the signal’s value changes (via .set() or .update()), all subscribers are notified

This happens automatically—you never manually specify dependencies.

┌─────────────────┐        .get()         ┌─────────────────┐
│     Effect      │ ────────────────────► │     Signal      │
│                 │                        │                 │
│  (observer)     │ ◄──── subscribes ──── │  (subscribers)  │
└─────────────────┘                        └─────────────────┘
                                                   │
                                              .set() / .update()
                                                   │
                                                   ▼
                                           notify all subscribers
                                                   │
                                                   ▼
                                           effects re-run

Dependencies are per-run

The dependency set is rebuilt on every run, not accumulated. An effect ends each run subscribed to exactly the signals and memos it read that time, so a dependency it stops reading stops waking it:

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

Effect::new(move || {
    if show_details.get() {
        render(details.get()); // subscribed only while the branch is taken
    }
});

show_details.set(false); // re-runs; `details` is no longer a dependency
details.set("...".into()); // does not re-run the effect
show_details.set(true);   // re-runs, and picks `details` back up
}

Disposal releases the subscriptions too — an effect (or a scope full of them) that is disposed leaves nothing behind in the signals it read, so a long-lived signal does not accumulate dead observers as components mount and unmount.

Execution Order

When several effects observe the same signal, they run in registration order — the order the Effects (or Memos) were created. This is a guaranteed contract, not an implementation detail.

That makes the “run me last” idiom well-defined: an effect registered after a tree of rendering effects observes their writes in the same synchronous flush, so a measuring effect can read the post-patch DOM rather than the previous frame’s.

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

// Registered first — writes.
Effect::new(move || resize_panel(width.get()));

// Registered second — reads what the first one just wrote.
Effect::new(move || {
    width.get();
    record_measurement(measure_panel());
});

width.set(250); // resize_panel runs, then record_measurement sees the new size
}

Two related guarantees follow from the same queue:

  • Effects run in the order they were queued. Notifying signal A then signal B runs A’s effects before B’s.
  • A signal written from inside an effect queues its observers behind the current flush, not ahead of it. Cascading updates therefore run breadth-first, and an effect already scheduled for this flush is never preempted.

Effects are still de-duplicated per flush: an effect observing two signals that both change in one batch() runs once, at the position of its first enqueue.

An effect does not wake itself

There is one exception to “queued behind the current flush”: a write that reaches the effect that is running is, for that effect, usually dropped rather than queued.

A write made while no batch is open flushes at once — and every effect body a flush runs is in that state, because a flush runs with the batch closed. So the flush happens while the body that made the write is still on the stack. It reaches the running effect, finds it running, and skips it. The write itself lands, and every other effect that reads the signal runs for it. The running effect keeps what it computed on this pass, the wake is not left in the queue for later, and the effect sees the new value the next time something else re-runs it.

“A write the body made” includes code the body calls, and the common case is a scope’s cleanup. An effect that disposes a scope runs that scope’s on_cleanup callbacks inside its own body — rsx!’s if, match and for all do when they swap a branch or remove a row — so a cleanup that writes a signal the disposing effect reads does not re-run it. For example, a for row whose cleanup writes a signal the list’s collection closure reads does not make the list reconcile a second time.

#![allow(unused)]
fn main() {
let trigger = Signal::new(0);
let tally = Signal::new(0);
let child = Scope::new();
child.on_cleanup(move || tally.update(|n| *n += 1));
let child = RefCell::new(Some(child));

Effect::new(move || {
    let t = trigger.get();
    let seen = tally.get();
    if t == 1 {
        let scope = child.borrow_mut().take();
        if let Some(scope) = scope {
            scope.dispose(); // its cleanup writes `tally`, which this effect read
        }
    }
    println!("saw tally = {seen}");
});              // prints "saw tally = 0"

trigger.set(1);  // prints "saw tally = 0"; tally is now 1, and this
                 // effect does not run again for it
trigger.set(2);  // prints "saw tally = 1"
}

This is deliberate, and it is what lets such an effect settle. Were the wake queued instead, an effect that disposes a scope on every run, whose cleanup writes a signal the effect reads, would schedule itself again on every run and never stop. So do not rely on an effect observing a write made by its own cleanup: have a different effect read that signal, or read the signal after the disposal in the same body.

The exception has an exception. When the body runs while a batch is open — an Effect::new or Effect::run called directly from an event handler, which is a batch, or inside batch() — its writes are queued rather than flushed, the queue drains only after the body has returned, and the effect does run again for its own write.

Batching Updates

Outside a batch, effects run after each update. To avoid redundant runs, use batch():

#![allow(unused)]
fn main() {
batch(|| {
    count.set(1);
    name.set("Alice".to_string());
    age.set(30);
    // Effects only run once, after the batch completes
});
}

A top-level batch() flushes synchronously: by the time it returns, every effect its writes woke has run. Batches also nest — a batch() called inside another batch’s closure joins the outer transaction, and the single flush happens when the outermost batch exits. (A batch() opened from inside an effect run by a flush is its own outermost batch — the flag is restored before the flush begins — so it still flushes before returning. An effect body that runs while a batch is still open is different: Effect::new runs its body immediately, so an effect created inside a batch closure runs inside that batch, and a batch() opened there joins the outer transaction instead of flushing.)

Until the flush, no effect has run: inside the closure — including after a nested batch() returns — effects have not executed, so a signal an effect writes still holds its old value. Memos are different: Memo::get inside the batch returns the new value. A write marks every memo that reads it stale synchronously, and the read recomputes it (see Memos). It used to return the pre-batch value, which made a handler that wrote a signal and then read a memo of it act on stale data. The DOM is different too: any NodeHandle call made in the batch first runs the effects queued so far (see below).

If the closure panics, the panic propagates and the batching flag is restored on the way out, so later writes flush normally. Nothing is flushed during the unwind itself — the effects the aborted batch queued stay pending and run at the next flush (the next unbatched write or outermost batch exit — every event handler is one, whether or not it writes anything; the runtime does not schedule one on its own).

Event handlers run as batches

You rarely need to call batch() yourself: rinch runs each of these as one, on desktop, on the web (the delegated listeners go through the same rinch_core::events::dispatch_* functions) and in embed:

  • onclick and every other data-rid handler (the element drag attributes included), oninput/onchange, onscroll, file drops;
  • the keyboard interceptor and the Escape dismiss stack (one transaction for both), the paste interceptor;
  • Drag on_move, on_end and on_cancel;
  • menu and tray callbacks, and their shortcuts;
  • a registered focus target’s on_focus_gained, on_focus_lost, on_key and on_ime;
  • each callback drained from the main-thread queue — Signal::send/update_send and run_on_main_thread called from another thread, and on native set_timeout, rinch-http and rinch-ws completions — one transaction per callback, not one for the whole drain, because they were queued independently and a later one may rely on an earlier one’s effects;
  • on the web, a set_timeout callback, a rinch-http completion and a rinch-ws event, which arrive straight from the browser.

Everything else that calls into your code is not batched by rinch, and its writes flush one by one as they always did. That includes, for example: run_on_main_thread called on the main thread (it runs its closure there and then, inside whatever transaction is open), set_selection_callback / set_selection_sync_callback, the configuration-change handler, on_child_inserted / on_child_removed observers, a RenderSurface’s event handler, the editor’s EditorHandle::on_change, on_link_click, on_link_hover, on_key, on_selection_change and on_caret_moved callbacks, and effects and memo computations themselves. Wrap any of those in batch() yourself if it writes several signals.

So a handler that writes five signals flushes effects once, when it returns, and the host is told once (on desktop, one ReRender, not five). A handler that writes no signal tells the host nothing — no ReRender, and an embedded context is not marked dirty — unless the theme changed with update_theme() since the host was last told (in this handler, or outside any handler), or effects an earlier batch left queued are still waiting (see the panic note above), which it then runs. A DOM change a handler makes directly, through a NodeHandle, is picked up by the host’s own dirty-node check, not by this notification — except, on desktop today, a scroll_into_view() request from a handler that changes nothing else, which waits for the next layout (#1145). Library code that keeps other state a host reads only when told (not a signal, not the DOM) calls rinch_core::note_host_change() when it changes, so the batch still notifies. What that means inside the handler:

  • Memos are current. Write, then read a memo of what you wrote: you get the new value.
  • Effects run when the handler returns — or earlier, the moment the handler touches the DOM (next section). Until then a signal an effect derives still holds its old value; read the source signal (or a memo) instead.
  • Glitch-free. An effect that reads several of the signals the handler wrote runs once, and sees all of the writes, never a mix of old and new.

Touching the DOM inside a handler

A handler that writes a signal and then works on the DOM itself was written expecting the write to have reached the DOM already — it always had:

#![allow(unused)]
fn main() {
button { onclick: move || {
    dialog_open.set(true);   // a Modal's effect shows the dialog and focuses its first field
    email_field.focus();     // …but this handler wants the email field
}, "Sign in" }

button { onclick: move || {
    messages.update(|m| m.push(new_message()));  // a `for` effect appends the row
    log.scroll_to_bottom();                      // …and this should reach it
}, "Send" }
}

So every NodeHandle operation made from handler code first runs the effects the handler has queued so far — focus(), scroll_into_view(), set_scroll_top(), scroll_to_bottom(), reads such as get_attribute, children, scroll_height or get_layout_bounds, and writes such as set_attribute alike. It is the analogue of a browser flushing pending style when script asks for layout. Program order is kept: the Modal’s own focus request is made first and the handler’s focus() overrides it; the new message row exists before the scroll; a set_attribute made after a signal write wins over the effect bound to that signal, instead of being overwritten when the handler returns. A handler that only writes signals still flushes exactly once. rinch_core::flush_pending_effects() does the same thing explicitly, for code that reaches the DOM some other way.

The rule applies to handler code, not to effects: a NodeHandle call inside an effect body or a memo computation never drains the queue, since that code runs inside a flush and must not run effects queued behind it ahead of their turn. A handler dispatched from an effect (an effect that calls dispatch_event, or a web focus() that fires a listener synchronously) is handler code again: its batch is a flush context of its own.

One way to trip over it in your own code. If a handler writes a signal and then makes a NodeHandle call while holding a borrow of a RefCell that an effect of that signal also borrows, the effect runs inside that call — and panics with BorrowError/BorrowMutError. Before handlers were batched the same effect ran at the write itself, before your borrow was taken; the conflict is the same, it just moved from the write to the DOM call. Take the borrow after the DOM call, or drop it before. (Editor queries such as is_mark_active are not DOM calls and run no effects.)

Writing a library that keeps its own RefCell state? A NodeHandle call inside a handler can now run user effects, and a user effect is free to call back into your API. If your code touches the DOM while it holds a borrow of its own state, that is a BorrowMutError waiting to happen. Hold rinch_core::reactive::suppress_effect_flush() for as long as the borrow lives — no effect runs inside it; the pending ones run at the next DOM access outside it, or when the batch ends — and call rinch_core::flush_pending_effects() just before taking the borrow, so the caller’s earlier writes have reached the DOM you are about to work on. The rich-text editor does exactly this around every borrow of its core, so tick.set(1); editor.command("toggleBold") in one handler, or two commands in a row with an on_change that bumps a toolbar signal, behave as they did before handlers were batched.

Two consequences to know about

An effect sees only where the handler ended up, never the steps. show.set(false); show.set(true) in one handler does not remount the if branch; removing a for item and putting one with the same key back keeps that row and its state; closing and re-opening an overlay in one handler neither restores nor re-captures focus. (A NodeHandle call in between flushes, so it does make the step visible — that is what the rule above is for.)

A handler that blocks keeps its transaction open. If a handler shows a native modal dialog (a file picker, a message box) that runs a nested platform event loop — which macOS and Windows dialogs can — its batch stays open until the dialog returns, and anything rinch runs meanwhile, such as a drained Signal::send or a timer, joins that batch: its effects run when the dialog closes, not when it arrived.

Reading Without Tracking

Sometimes you want to read a signal without creating a subscription. Use untracked():

#![allow(unused)]
fn main() {
Effect::new(move || {
    // This creates a subscription
    let count = count.get();

    // This does NOT create a subscription
    let name = untracked(|| name.get());

    println!("Count: {count}, Name: {name}");
});
// This effect only re-runs when `count` changes, not when `name` changes
}

untracked hides only the observer directly above it. That matters only while an effect’s run is nested inside another effect’s run — the first run of an Effect::new written inside another effect’s body, for instance. A read inside untracked there is recorded on the outer effect. Later re-runs of the inner effect normally come from a flush with no effect beneath them and are unaffected (issue #932).

App handlers never subscribe the effect that calls them

A component often calls its app’s handler from inside one of its own effects — a coordinating effect that ends in onchange, a value_fn binding that reports back. The handler is app code, and it routinely reads signals: the controlled idiom onchange: move |v| if v != store.get() { store.set(v) } reads the store it writes. If that read counted, the component’s internal effect would be subscribed to the app’s store, and a write to the store from anywhere else would re-run it out of turn.

So every app-callback type’s invoke — Callback, ValueCallback<T>, InputCallback, FileDropCallback and ScrollCallback — runs the handler with no observer in view, however many effects deep the call is (issue #285). Handlers respond to events; effects respond to signals. A component author does not need to wrap cb.invoke(..) in untracked any more.

Only tracking changes: batching is untouched, so a handler’s writes inside an event handler still flush once, when the batch ends. An effect the handler creates, or one its writes run, tracks normally.

The same holds for the other app callbacks rinch runs synchronously from a call you can make inside an effect (issue #931):

  • the rich-text editor’s EditorHandle hooks — on_change, on_selection_change, on_caret_moved, on_key, on_link_click and on_link_hover — so an app effect that calls set_selection, runs a command or offers a key is not subscribed to a popup hook’s own open signal;
  • the child observers (rinch_core::dom::on_child_inserted / on_child_removed), which every for reconcile, if branch swap and component re-render reaches from inside its effect;
  • the selection and selection-sync callbacks (query_selection_ranges, fire_selection_sync), the keyboard and paste interceptors (dispatch_keyboard_event, dispatch_paste_event), the dismiss stack (dispatch_dismiss) and Drag::cancel’s on_cancel.

Editor plugin code is untracked the same way (issue #943): apply, init_state, decorations, handle_paste, a plugin’s commands and input rules run under the editor’s own borrow, inside whatever effect called the handle, so a spellchecker whose decorations() reads an app spellcheck_on signal does not subscribe every effect that moves the selection. A plugin that should react to a signal needs something that re-asks it — an effect of its own that dispatches a transaction when the signal changes. Plugin code your own update closure calls (below) is part of that closure, and tracked. The collaboration outbound sink runs untracked too (issue #948): a transport closure that reads a connected signal does not subscribe the effect that made the edit.

A closure you pass as the call itself is not one of these: the build closure of EditorHandle::update(|state| …) runs as part of your own code, and a signal it reads is your effect’s dependency like any other.

If you write a component or library that stores an app’s callback and calls it later, call it through rinch_core::untracked_handler(|| cb(..)) for the same reason. The five callback types do this in their invoke; a bare Rc<dyn Fn()> does not. A closure a component stores and calls itself — an Rc<dyn Fn()>, a render_fn — is an ordinary call and is tracked like any other code in the effect; that is what a _fn prop such as value_fn relies on, and why the choice is yours.

Memory Management with Scopes

Reactive resources continue to exist until something disposes them. Usually that something is implicit: a Scope::run(f) makes that scope the ambient owner for f, so every Signal, Memo, Effect and event handler created inside is attributed to it and freed when it is disposed. Rendering runs under a scope, so component state is cleaned up without you asking.

#![allow(unused)]
fn main() {
let scope = Scope::new();

scope.run(|| {
    let count = Signal::new(0);          // owned by `scope`
    Effect::new(move || { count.get(); }); // owned by `scope`
});

scope.dispose(); // both are freed
}

scope.add_effect(effect) is the manual path, for an effect built outside the scope that should still die with it.

With no ambient owner, a resource has app lifetime — which is why signals created in main() or in startup code keep working untouched. Because handles are Copy and can outlive their values, reads of a freed handle panic while writes are warn-once no-ops; use try_get() / is_alive() when a handle may legitimately be gone. See Lifetimes for the full story.

Next Steps

  • Signals - Reactive state containers
  • Memos - Cached computed values
  • Stores - Shared state with action methods
  • Effects - Advanced: side-effects that track dependencies

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 — a Signal<T> is an index and a generation counter into a thread-local slot map, not a smart pointer. The generation is what lets a handle outlive its value safely: a recycled slot carries a generation no stale handle holds, so a freed signal is detected, never silently confused with a new one.

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

Liveness

A signal created during a render is owned by that render and is freed when it goes away (see Lifetimes). Because Signal<T> is Copy, a handle can easily outlive the value:

CallOn a freed signal
get() / with()panics
set() / update() / set_if_changed()no-op, plus a warning
try_get() / try_with()None
is_alive()false

Reads assert and writes forgive, because a write can come from somewhere that cannot be asked to check — a background thread mid-send(), a timer that has not fired its last tick yet — while a read has nothing to return.

#![allow(unused)]
fn main() {
// From a long-lived callback or a worker holding a Copy handle:
if let Some(n) = count.try_get() {
    println!("still here: {n}");
}
}

is_alive() does not subscribe the current observer. Liveness is not reactive, and tracking it would resurrect the dependency you are trying to drop.

If a signal is deliberately meant to outlive its component, say so explicitly with Signal::new(0).leak() rather than relying on it accidentally surviving.

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.

A worker may start sending before the app does — spawned in main() ahead of App::run(). Such a write is queued and applied once the app has started (issue #1541); it used to be dropped. That holds for a thread that has created no signals of its own: rinch tells the thread that owns a signal from a worker by whether it has ever created one, until the host registers its main thread.

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

An effect stops when it is disposed — explicitly, or by the scope that owns it:

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

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

Dropping the handle does not dispose the effect. This is deliberate and it is what every example on this page relies on: Effect::new(move || …); with no let drops the returned handle immediately, and the effect keeps running anyway. The closure lives in a registry keyed by id, not in the handle, because an effect is queued and run by id long after the caller has forgotten it.

So an effect has exactly two ends:

  • Explicit — effect.dispose().
  • Owned — an effect created during a render belongs to that render, and is disposed when the component unmounts, the if branch flips, or the for row is reconciled away. This is the usual case, and it is why RSX closures never need cleaning up by hand.

An effect created with no ambient owner and no retained handle — from main(), from startup code, from a detached callback — runs for the life of the thread. That is the right default for app-wide wiring and a leak everywhere else, so prefer creating effects during a render, where something owns them.

Disposal is what reclaims the registry entry, not merely what silences it: everything the closure captured is dropped with it.

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

The value type must be Clone + PartialEq. PartialEq is what the equality cut-off below compares with; a type that cannot implement it meaningfully can implement it as “never equal” (fn eq(&self, _: &Self) -> bool { false }), which gives back the old behaviour of waking every dependent on every recompute.

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
  5. Cut off on equality — a recompute that produces a value equal to the previous one wakes none of the memo’s dependents
  6. Always current — a read straight after a write to one of its sources sees the new value, even inside a batch() or an event handler that has not returned yet
Signal(count) ──► Memo(doubled) ──► cached value
    │                   │
  .set(3)            .get()
    │                   │
    ▼                   ▼
 marks memo       recomputes if
 as "dirty"          dirty

The equality cut-off

The pattern it exists for is per-row derived state:

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

for row in rows.get() {
    let id = row.id;
    let is_selected = Memo::new(move || selected.get() == id);
    div { key: id, class: {move || if is_selected.get() { "row selected" } else { "row" }} }
}
}

Every row’s memo reads selected, so a selection change recomputes all of them — a comparison each. But only two answers change (the row that lost the selection and the row that gained it), so only those two rows’ class effects run. Without the cut-off every row’s effect ran on every click.

How it works: a write marks the memos that read it stale at once (so a read straight after the write is current), and a memo’s dependents are woken as maybes. Before running a maybe, the flush brings the memos it read up to date and compares each one’s version — a counter that moves only when a recompute produces an unequal value — with the version the dependent saw last time. None moved: it is skipped. An effect that also reads a signal that changed is not a maybe, and runs as usual; signals have no cut-off (set notifies whether or not the value changed — use set_if_changed for that).

The cut-off chains: a memo that reads a memo which recomputed to an equal value is re-validated without running its own computation. A source memo that has been freed (its scope disposed) counts as changed, so a reader that falls back with try_get() recomputes once and sees None rather than keeping a value computed from a memo that no longer exists.

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.

The name’s case is what makes it a component. rsx! reads it to tell markup from a component, so MyThing { … } calls your function while my_thing { … } is looked up as an HTML tag. A lowercase #[component] function is still perfectly good Rust — it just has to be called as one: { my_thing(__scope) }. Writing my_thing { … } is a compile error that says so (issue #528); before that it rendered an empty <my_thing> element and never called the function.

#![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: TablerIcon::CheckSome(TablerIcon::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}).

Keyboard Focus

Exactly one thing in a rinch window owns the keyboard at a time. The runtime calls that the focus arbiter: an <input>, a <select> with its popup open, a rich-text editor, a render surface, or a generic focusable DOM node. Moving focus tears the previous owner down before installing the next, so two widgets can never both believe they are typing.

Most of the time you never touch this — put tabindex="0" on an element and it joins the Tab order, paints :focus, and activates on Enter/Space. This page is about the other case: a custom component that takes keyboard input of its own (a code editor, a canvas grid, a keyboard-driven list) and needs to know when it gained the keyboard, when it lost it, and what keys arrived meanwhile.

Making an element focusable

#![allow(unused)]
fn main() {
rsx! {
    div { tabindex: "0", "I can be tabbed to" }
}
}

Focusability comes from the tag or from an explicit tabindex, and an explicit one always wins — the browser rule. The value is read the browser’s way too (HTML’s rules for parsing integers): leading whitespace is skipped and the number ends at the first non-digit, so " 3", "2.5" and "3abc" are all tab indices (3, 2, 3); a value with no leading digits, or past the 32-bit range, is not a tabindex at all.

Focusable by tag
<button>, <select>, <textarea>, <input>always
<a>only with a non-empty href — a bare <a> is not a link

<summary> is deliberately not in the set: rinch has no <details> disclosure behaviour, so a focusable <summary> would be a Tab stop that does nothing.

A focusable element that is visually hidden but laid out — opacity: 0, or position: absolute off-screen — is in the Tab order, and that is correct: .sr-only text and skip links depend on exactly that, as they do in a browser. What leaves the order is a zero-sized box, display: none, and visibility: hidden/collapse. Neither is data-rid a focusability signal — the DropdownMenu’s full-screen dismissal backdrop carries one, and so do clickable cards, table rows and list items; none of those should be Tab stops.

AttributeEffect
tabindex="0"Reachable by Tab, focusable by click and by NodeHandle::focus(). Needed on anything that is not focusable by tag — a div you are driving yourself
tabindex="-1"Not in the Tab order, but still focusable by click and programmatically — the standard “focus this dialog when it opens” idiom, and the way to take a <button> out of the Tab order
disabled / data-disabledTakes no focus at all, and accepts no keyboard edit. Both spellings count — the component library writes the HTML one, the runtime’s own widgets write the data- one
readonlyFocuses, moves its caret, selects and copies like any other field — and refuses every command that would change its text (typing, delete, cut, paste, undo/redo)
data-nofocusA press here takes the click but not the keyboard: whatever is focused stays focused. Read anywhere on the pressed element’s ancestor chain, so a toolbar carries it once
data-trap-focusTab and Shift+Tab cycle inside this element instead of walking the page. What Modal, Drawer and Popover write while they are open — see Containing Tab inside an overlay

All five are boolean attributes: their presence is their value, so disabled, disabled="" and disabled="disabled" say the same thing. To say enabled, remove the attribute — a bool in rsx! does that for you (button { disabled: {move || busy.get()} }).

The two HTML attributes, disabled and readonly, are read by presence alone, exactly as a browser reads them: disabled="false" disables and readonly="false" is read-only, on desktop and on the web alike (issue #612). That applies to every tag the attribute reaches, so a <textarea readonly="false"> is read-only too.

rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop (an overlay backdrop that answers a press of any button, #1093) and data-scroll-lock-exempt (a scroll container an overlay’s scroll lock leaves alone, below) are the exception, and the only one: there the literal "false" turns the attribute off. data-nofocus, data-trap-focus and data-backdrop are read that way on both backends; data-disabled is a desktop attribute with no web reader, because the browser does not know it, and so is data-scroll-lock-exempt, because the web’s lock has nothing to exempt. Only "false" is excused — "0" is on, matching the web’s [data-nofocus="false" i] selector. Reach for the escape when you are writing the attribute by hand with set_attribute and would otherwise have to branch between writing and removing it; a bool in rsx! already removes it for you.

A disabled <fieldset> disables every control below it, which is what the element is for. HTML’s carve-out is honoured: controls inside the fieldset’s first <legend> stay enabled, so the control that re-enables the section can live there. No other tag’s disabled reaches its subtree — a disabled <button> does not disable a <span> inside it.

A press on a disabled control also paints no DOM :focus, so a focus ring never appears on something that owns no keyboard.

The same rule drives the CSS :disabled and :enabled pseudo-classes, so a control that refuses input also looks disabled — input:disabled { ... } matches, and a control below a disabled <fieldset> matches with it (the first <legend> exemption included). Styling is deliberately narrower than focus in one way: HTML defines both pseudo-classes over form elements only, so a <div data-disabled> is still removed from the Tab order but does not match :disabled — matching it would style on desktop what a browser, and therefore rinch-web, leaves alone.

A native <select> refuses the whole popup interaction, not just the last step: a disabled one opens no popup, and one disabled while its popup is already up dismisses it and commits nothing rather than firing oninput / onchange.

Disabled is re-checked at edit time, not only at focus time, so a field that goes disabled while focused — a reactive disabled prop re-rendering under a live caret — stops accepting keys immediately, and releases the keyboard, the way a browser moves focus to the body. The one thing that release does not do is fire the field’s data-onchange commit: everywhere else that commit is load-bearing (a window blur deliberately keeps the claim so alt-tabbing cannot fire it), but a control going disabled is not the user committing an edit, and browsers dispatch no change for it either.

Focus arrives three ways, and all three go through the same arbiter:

  • Tab / Shift+Tab — walks the focusable elements in the browser’s sequential order, and paints the :focus-visible keyboard ring: every positive tabindex first, in ascending order, then everything at 0 or focusable by its tag, ties in DOM order — tabindex="2" before "3" before a plain <button>, wherever each sits in the document. Inside a trap_focus overlay the same order applies to the overlay’s own controls. Tab from an element that is not in that order (a clicked tabindex="-1" node, even one inside another Tab stop, or one whose box has just gone) goes to the next Tab stop after it in DOM order, and Shift+Tab to the previous one, as Chrome does. What an opening dialog focuses is still its first control in DOM order, not the first in Tab order — HTML picks it that way.

  • A mouse press — claims the nearest focusable ancestor-or-self of whatever was hit, exactly as a browser does, and with any button: a right or middle press focuses too, as a browser’s mousedown does (measured in Chrome 153), and it does so before a data-oncontextmenu handler or the built-in text menu runs. Only :active is the primary button’s alone, and a press inside a focused rich-text editor leaves the keyboard with the editor whatever the button. Pointer focus does not paint the :focus-visible ring. A press that resolves to something other than the current claim holder moves the keyboard there — a nested focusable inside a focused node counts as “somewhere else” — and a press that resolves to nothing releases it.

    This applies to tabindex="-1" too — it keeps an element out of the Tab order, but it does not keep a click from focusing it, in rinch or in a browser. A control that must not steal focus from the field it sits beside — a toolbar button over a rich-text editor, a spinner next to a number input — needs data-nofocus.

  • node.focus() / request_focus(node_id) — programmatic, also no ring.

Selecting a text control’s text programmatically

NodeHandle::select() and NodeHandle::set_selection_range(start, end, direction) (issue #552) are focus()’s selection counterparts, for the “a rename box opens with its name filled in and selected, so the first keystroke replaces it” shape — set_selection_range matches the DOM’s own HTMLInputElement.setSelectionRange(start, end, direction) exactly, including its units: start/end are UTF-16 code units, not bytes and not chars, so a component written once targets desktop and rinch-web with the same offsets ("🙂".encode_utf16().count() is 2, not 1). direction is SelectionDirection::Forward (the anchor is start), Backward (the anchor is end), or None (unspecified, applied the same way Forward is). select() selects the control’s whole text — exactly set_selection_range(0, <the UTF-16 length>, Forward).

#![allow(unused)]
fn main() {
// `name_input` is the captured `NodeHandle` for the rename `<input>` (built
// once, outside the handler, the way `email_field` is above).
button { onclick: move || {
    editing.set(true);   // an `if` effect mounts the rename input
    name_input.focus();
    name_input.select(); // the whole name, ready to be typed over
}, "Rename" }
}

That ordering works because touching the DOM inside a handler flushes the queued effects first: by the time name_input.focus() runs, the if effect has already mounted the input editing.set(true) asked for.

Both calls work on a control that does not hold the keyboard yet, the same way a browser’s own setSelectionRange()/select() do: the range is set immediately and simply shows up the first time the control is focused — a component does not need to call focus() first, or in any particular order relative to select()/set_selection_range(). Like focus(), both are no-ops before this handle is mounted — and, on desktop, also a no-op on a node that is not a text control (a checkbox, a <select>, a generic tabindex node): such a node never installs the state a selection would apply to, so there is nothing to apply it to or hold it for, the same divergence a browser’s own thrown-and-discarded InvalidStateError would be papering over. A mouse click that focuses a field places its own caret and clears a selection that was pre-armed for it but never applied — calling set_selection_range() on a field and then clicking somewhere else in it does what you would expect: the click wins.

A focused <select> is closed, like a browser’s: Enter, Space or Alt+Down opens its popup, and the popup then owns the keyboard until it commits or is dismissed — at which point focus returns to the closed control, so Tab carries on from there rather than restarting. (A click outside the popup is the exception: it belongs to whatever it landed on.) The pointer opens and picks with the primary button only, as in a browser: a right or middle press on the closed control does not open it, and one on an option picks nothing and leaves the list open — but a press of any button outside the list dismisses it. Everything else focusable activates the nearest ancestor-or-self data-rid on Enter/Space, which is what makes div { tabindex: "0", onclick: … } behave like a button — and what makes Space on a Checkbox’s visually hidden <input> toggle the <label> that wraps it.

The Select component (not a native <select>) behaves the same way on both backends (issue #434): its trigger keeps the focus while the list is open, ArrowUp/ArrowDown move a highlight (wrapping at the ends), Home/End jump, Enter commits the highlighted option, Escape closes without committing, Tab closes and moves on, and typing jumps to the first option whose label starts with what was typed. Space commits like Enter, except while a type-ahead prefix is being typed, when it extends it (“new y” finds New York). The highlight starts at the selected option and is announced through aria-activedescendant. A key with Ctrl, Alt or Meta held is left to the app. The list owns keys only while the focus is in the Select: if something moves the focus elsewhere while it is open (a shortcut focusing a search box, a script), the list closes and the keys go to wherever the focus went.

Taking the click without the keyboard

An editor toolbar has a problem every GUI toolkit has to answer: pressing Bold must run the command without blurring the editor, or the command reads a selection that is no longer there. Browsers answer it with preventDefault() on mousedown, which suppresses the focus change while still delivering the click.

data-nofocus is that mechanism:

#![allow(unused)]
fn main() {
rsx! {
    // The whole toolbar opts out at once — every control inside it takes its
    // click without taking the keyboard.
    div { data-nofocus: "", class: "toolbar",
        button { tabindex: "0", onclick: move || ed.command("toggleBold"), "B" }
        button { tabindex: "0", onclick: move || ed.command("toggleItalic"), "I" }
    }
    Editor { editor: ed.clone() }
}
}

The rules:

  • It can be written on a component: Group { data-nofocus: "", … } or Paper { data-nofocus: "", … } puts it on the component’s root element, so a toolbar built from components needs no wrapper div (Attributes on a component).
  • It is read anywhere on the pressed element’s ancestor chain, so a toolbar carries it once instead of every button in it. Put it on the toolbar, not on a big content region — a press inside it suppresses the browser’s default, which includes starting a text selection.
  • It protects whatever holds the keyboard — the rich-text editor, an <input>, a render surface, another focusable node — not just the editor.
  • The click still fires. data-rid dispatch is untouched.
  • A text field inside a data-nofocus region still focuses normally. A link-URL field in a toolbar has to be usable, so the field’s own claim wins over the region’s opt-out.
  • Boolean attribute, same rule as data-disabled: present means on whatever the value, and the explicit "false" opts out — one of rinch’s own three attributes where that escape exists.
  • It works on both backends. On the web it becomes preventDefault() on the pointerdown.

Containing Tab inside an overlay

A dialog that lets Tab walk out into the page behind it is not really a dialog: the keyboard user ends up editing a form they cannot see. Modal, Drawer and Popover all take a trap_focus prop for this — true by default on the first two, false on Popover, which is not modal.

#![allow(unused)]
fn main() {
rsx! {
    Modal {
        opened_fn: move || open.get(),
        onclose: move || open.set(false),
        trap_focus: true,          // the default; `false` lets Tab out
        TextInput { placeholder: "Name" }
        Button { onclick: move || save(), "Save" }
    }
}
}

While the overlay is open its root carries data-trap-focus, and both backends read it: Tab and Shift+Tab cycle the focusable elements inside that element and wrap at its ends, instead of walking the whole document. When it closes the attribute is removed and Tab goes back to the page.

You can put the attribute on anything, not just these three components — it is a plain contract, the way data-nofocus is:

#![allow(unused)]
fn main() {
div { data-trap-focus: "", class: "command-palette",
    input { }
    button { "Run" }
}
}

The rules:

  • Which trap wins. Whichever one currently holds the keyboard; if none does, the last one in document order. Nesting therefore resolves inside-out — a modal opened from a modal is rendered deeper, so later — and when the inner one closes the outer takes over with focus unmoved. z-index is not consulted, so a raised-but-earlier overlay loses to a later one; that matches the dismiss stack, which answers Escape in the same order.
  • A trap with no box is not a trap. A closed Modal’s root is display: none, so it is skipped even if the attribute were left on it. Both guards are real, and the component relies on the first: it removes the attribute rather than writing "false" into it.
  • Only Tab is contained. A click outside the overlay still moves focus out of it, and so does a scripted focus(). That is what a non-modal <dialog> does; a browser’s showModal() goes further and marks the rest of the page inert, so even a scripted focus behind it is refused — measured in Chrome 150. rinch has no inert and no showModal semantics, so trap_focus is a Tab rule, not a modality barrier. The backdrop’s close_on_click_outside is what an outside click is for; full modality would need inert, which rinch does not have.
  • A trap with nothing focusable inside it swallows Tab. That is what containment means when there is nowhere to go — and on the web the same is true of a trap whose every control the browser refuses to focus (all of them inside a <fieldset disabled>, say): the key is consumed and focus stays put, rather than the stepping-on above running forever.
  • Boolean attribute, same rule as data-nofocus: present means on whatever the value, and the explicit "false" opts out.
  • Both backends, different code. Desktop starts its own focusable walk at the trap; rinch-web collects the trap’s focusable descendants with a CSS selector and calls focus() itself. The two sets are therefore computed separately and agree only as far as each is written to — desktop’s notion of focusable is already documented as broader than HTML’s in places. Trapping inherits that difference rather than creating it. On the web the browser is the authority, not rinch’s selector: a focus() it declines (a control inside a <fieldset disabled>, a tabindex it parsed differently) is stepped over rather than trusted, so an element rinch listed and the browser will not focus cannot stall the cycle.
  • Some browser Tab stops are not rinch Tab stops, on either backend: contenteditable elements, <iframe>, <summary>, <audio controls> and <area href> are reachable by Tab in a browser and are in neither backend’s focusable set. An overlay containing one loses it while trapped — and outside a trap those elements are not desktop Tab stops either, so this is the focusable set’s shape rather than something trapping introduces. Give such an element an explicit tabindex="0" if it has to be reachable.

Moving focus in, and giving it back

Containment is half of what a dialog does. The other half is that opening one takes the keyboard and closing it gives it back (issue #695), and both are tied to the same trap_focus prop — trap_focus is rinch’s spelling of “this overlay is modal”, and a browser moves focus for showModal() and not for show(). An overlay that wants one without the other cannot ask for it.

On open, the overlay remembers whatever holds the keyboard — usually the button that was clicked — and then focuses inside itself:

ComponentWhat it focuses
Modal, Drawerthe autofocus descendant, else the first focusable
Popoverthe autofocus descendant, and nothing without one

Popover’s rule is the HTML popover API’s, not the dialog’s: an auto popover runs its focusing steps only for an element that asked. A popover that grabbed the keyboard on every open would interrupt whatever the user was typing in.

On close — and on unmount while still open, which is what if show { Modal { … } } does — the keyboard goes back to the remembered element, provided it can still take it (see below). If it cannot, the claim is released rather than left inside the overlay that has just gone. Focus the user moved out of the overlay before it closed is left alone entirely: neither released nor restored over, which is HTML’s own dialog rule.

Nesting needs no special case: an inner overlay remembers whatever the outer one focused, so closing the inner restores into the outer and closing the outer restores to the page.

Three things to know about the machinery:

  • Desktop decides both a turn later. An overlay opening or closing is a class change in the same effect flush, so at that instant its children still carry the zero-size boxes their display: none ancestor gave them — and on a close, whether the remembered opener can still take the keyboard is the same kind of question about boxes. The desktop backend therefore parks the request and resolves it after the next layout, which is the turn a signal write already triggers. rinch-web answers on the spot, because the browser lays out on demand and refuses a focus() it should refuse. The browser’s focus listeners run inside these focus moves (NodeHandle::focus, an overlay opening or closing) with the document free: a blur, focusin or onchange handler of yours that they run may use any NodeHandle. The same holds when a NodeHandle verb takes the focused element out of place (see When the focused element goes).
  • “Still there” means it can still take focus, not merely that it is attached. An opener that went disabled while the dialog worked, or one that lives inside an outer overlay closed before this one, is connected and unreachable; the keyboard is released instead. What is not checked is identity: a node id freed and handed to a different, attached node would still be focused — the recycled-slot hazard of issue #304, which desktop has independently of this.
  • A Popover that declines the move is still a Tab trap when trap_focus is on. Focus stays where it was, and the next Tab enters the popover and is contained there. That is containment’s own rule (the last visible trap wins), not a consequence of the move policy.

The portable API this rests on is three DomDocument methods, reachable on any NodeHandle: active_element(), focus_into(policy) and restore_focus(opener). A component that builds its own overlay can use them directly. There is deliberately no bare blur(): releasing the keyboard is never the whole answer, only the fallback half of a restore, and a caller that could only blur would have to make the decision restore_focus exists to make.

Registering a focus target

register_focus_target attaches callbacks to a focusable node. It does not change who can be focused — it changes what your component is told about it.

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

#[component]
fn key_grid() -> NodeHandle {
    let focused = Signal::new(false);
    let cursor = Signal::new(0usize);

    let grid = rsx! {
        div {
            tabindex: "0",
            class: {move || if focused.get() { "grid focused" } else { "grid" }},
            "cell "
            {move || cursor.get().to_string()}
        }
    };

    register_focus_target(
        &grid,
        FocusEntry::new()
            .on_focus_gained(move || focused.set(true))
            .on_focus_lost(move || focused.set(false))
            .on_key(move |k| match k.key.as_str() {
                "ArrowRight" => {
                    cursor.update(|c| *c += 1);
                    true // consumed
                }
                "ArrowLeft" => {
                    cursor.update(|c| *c = c.saturating_sub(1));
                    true
                }
                _ => false, // let the runtime have it
            }),
    );

    grid
}
}

Every callback is optional; FocusEntry::new() with none of them is legal and registers the node without asking for anything back.

What fires, and when

CallbackFires
on_focus_gainedTab onto the node, a press on it (or on any of its children), focus() / request_focus, and when the window regains OS focus while this target still holds the claim
on_focus_lostAnything else takes the keyboard — another registered widget, an <input>, a <select>, the rich-text editor, a render surface — a press landing outside, and when the window loses OS focus
on_keyEvery KeyDown and KeyUp while this target holds focus, before the runtime’s own handling — read k.kind (or k.is_up()) to tell them apart
on_imeEvery IME composition event while this target holds focus (see IME below)

Presses and releases

k.kind is KeyEventKind::Down or Up; k.is_down() / k.is_up() are the shorthands. OS auto-repeat arrives as Down, and KeyEventData does not yet distinguish it from a fresh press. The platform event does, since issue #463 — PlatformEvent::KeyDown carries a three-state KeyRepeat (Fresh / Repeat / Unknown), filled in by winit on desktop and by repeat_count() on Android, and that is what the runtime’s own once-per-press Enter/Space activation reads. Surfacing it to on_key needs the same three-state answer (a plain bool would report a held key as fresh wherever a backend cannot see, which is the divergence a document-level hook exists to avoid) and so needs a home for the type that rinch-core can reach. Tracked as #797.

A press and its release are spelled by the same rule, from the same fields, so pairing them by k.key works by construction — which is what “is W still held” needs. Concretely: the platform event’s logical_key carries the full layout-produced KeyboardEvent.key value on the press and the release alike, case included. On AZERTY, the key labelled A is "a" on the way down and on the way up, not "a" down and "q" up; Shift+A is "A" both ways, and Shift+1 is "!" both ways — the same strings a browser reports, so the same consumer code works against rinch-web. Case is identity, so a Shift pressed mid-hold changes what the eventual release spells ("w" down, "W" up) — exactly as in a browser; track held keys by the physical k.code, or fold case at the comparison, if that matters to you.

On Android a hardware key’s release is spelled as its press was reported, whatever happened in between: "W" down with Shift held comes up as "W" even if Shift went up first, and a letter that combined with a dead key ("é") comes up as that same string. A release whose press the app never saw (a key already held when the app came to the front) is spelled from the layout with the modifiers held at the release. An auto-repeat is still spelled from what it types: after a dead key the press is "é", the repeats are "e" and the release is "é", so track held keys by k.code there too (#1418). Text the soft keyboard commits goes through InputConnection and has no key events; a raw key event an IME sends (sendKeyEvent, typically Backspace and Enter) comes through the same translation as a hardware key, release included.

Two things to know:

  • A release is delivered to whoever holds the claim at release time. A focus change mid-chord can therefore hand a target a release it never saw pressed — treat on_focus_lost as “everything is up” if you track held keys.
  • A release’s return value is ignored. There is nothing downstream of it to suppress, and the runtime’s own release work (clearing the Enter/Space activation latch) must happen whatever a handler thinks — otherwise a consumed release would strand the latch and swallow the next press.

on_key returns true to consume the key. A consumed key stops there: no Tab navigation, no Enter/Space activation, no DevTools shortcut. Returning false (or not setting on_key at all) leaves every one of those working exactly as it would for an unregistered node, so registering costs you nothing you did not ask for.

k.key is spelled the way the browser spells KeyboardEvent.key, on desktop as on rinch-web (which forwards event.key() unchanged), and k.code the way it spells KeyboardEvent.code. That includes the spacebar: k.key is " " and k.code is "Space", and k.is_space() answers it on every backend. (Desktop reported k.key == "Space" until issue #1161; code that matched that should match k.code == "Space" or k.is_space() instead.) The key is resolved in four steps:

  1. A key with a fixed spelling wins over the text it would insert — "ArrowLeft", "Enter", "Escape", "Tab", "PageUp", "F1"…"F12", "Shift", and the spacebar’s " ", so a release (no text) and a chord (text suppressed) spell it like a plain press.
  2. Otherwise the inserted text wins, so a non-QWERTY layout reports the letter actually typed rather than the physical QWERTY position: the AZERTY key at the QWERTY-Q position is k.key == "a", and Shift+A is "A".
  3. Otherwise the layout’s key value. A modifier suppresses the inserted text, but the layout-produced KeyboardEvent.key value survives it — so a chord keeps step 2’s promise: on AZERTY, Ctrl plus the key labelled A is k.key == "a" (and Ctrl+Shift makes it "A" — the value is case-accurate), the same letter the editor’s own keymap acts on. It also names shifted punctuation (Ctrl+Shift+1 is "!" where the layout puts one), a dead key ("Dead"), and keys rinch has no KeyCode of its own for but the platform names — CapsLock, media keys.
  4. Otherwise the physical key’s US-layout character, for events that carry no layout value at all (the debug channel, injected and embedded events): Ctrl+S is "s", Cmd+1 is "1", Ctrl+- is "-".

k.code is always the physical key ("KeyS", "Digit1").

Two things to know. k.key is case-accurate, browser-style — Ctrl+S is "s", Ctrl+Shift+S is "S" — and a press and its release spell alike (both read the modifier state of their own moment), which is what pairing them by k.key relies on. And a key bound to a native menu accelerator is consumed by the menu before the document sees it — but only the press: the release still arrives (the menu consumes nothing on the way up), so it is one more source of a release with no visible press.

The one key that still reports nothing is one rinch has no KeyCode for (k.code == "Other") arriving with no layout value and no inserted text. A real keystroke carries the layout value (unless the platform itself cannot identify the key), so this is mostly the injected regime: the debug channel names only single characters and the named keys, so an injected Ctrl+/ has no spelling to fall back to and never reaches the hook. From the keyboard those keys are fine: Ctrl+/ reports "/" through step 3, and unmodified the character arrives as the inserted text.

Both focus callbacks run after the transition is complete: the arbiter and the DOM :focus state are already installed, so a callback may re-enter the runtime freely — move focus again, mutate the DOM, save a document.

Enter and Space

A focused node that does not consume Enter/Space gets the runtime’s default: they dispatch the onclick handler of the nearest ancestor-or-self that has one, once per physical press. That is what makes div { tabindex: "0", onclick: … } behave like a button. If your widget wants Enter for itself, consume it in on_key.

Unmounting

Unmounting the component deregisters silently. on_focus_lost does not fire, even if the node held the keyboard at the time — by then the component’s scope has been disposed and its signals freed, and calling back into it would panic. Do your teardown in the component’s own cleanup path, not in on_focus_lost.

The arbiter notices the vanished target on its next key dispatch and drops the claim, so keys fall back to the global handlers rather than disappearing.

Window focus

When the window loses OS focus, the focused widget keeps its claim — it is only notified, and notified again when the window comes back. This is browser behaviour, and it is deliberate: releasing focus on every alt-tab would fire onchange on whatever field the user was typing in each time they switched windows.

So the pair to expect is on_focus_lost on window blur, on_focus_gained on window refocus, with no key routing in between. Use it to hide a caret and idle a blink timer. While the window is blurred, rinch reports the OS IME disabled, so a candidate box follows the window that actually has the keyboard.

The runtime does the same for its own caret: the rich-text editor’s caret stops blinking while the window is blurred and shows solid, resuming from the solid phase on refocus. The blink is the event loop’s only timed wake, so a backgrounded rinch app now idles completely instead of waking twice a second to animate a caret nobody can type into. The selection highlight is unaffected — the claim is still held, so a blurred window still shows what is selected.

IME

A widget with its own text model can take IME composition — CJK conversion, autocorrect, dead keys, a swipe keyboard — by registering on_ime. That is what declares the target a text target: the runtime then switches the platform input method on while the widget holds the keyboard, and routes every one of the five portable ImeEvent variants to it — the same contract the rich-text editor and a built-in <input> consume. A registration without on_ime turns nothing on, so a focusable card or a custom checkbox never pops a candidate window.

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

register_focus_target(
    &node,
    FocusEntry::new()
        // Where the OS puts its candidate box, in logical window pixels.
        .caret_rect(move || Some(caret.get()))
        .on_ime(move |e| match e {
            // A transient overlay you render and discard — never document text.
            ImeEvent::Preedit { text, cursor } => model.set_preedit(text, *cursor),
            // The conversion the user chose. Insert it as one edit.
            ImeEvent::Commit(text) => model.insert(text),
            // Composition ended with nothing committed.
            ImeEvent::Disabled => model.clear_preedit(),
            _ => {}
        })
        .on_focus_lost(move || model.clear_preedit()),
);
}

caret_rect is (x, y, w, h) in logical window space — CSS pixels from the window’s top-left, the same space layout bounds are reported in. Do not pre-multiply by the display scale factor; the shell hands the rect to the platform as a logical size and the platform scales it. Return None when there is no caret; placement then falls back to the platform’s default. The provider is polled every time rinch reconciles the window’s IME state (once per event-loop iteration), so the candidate box follows the caret with nothing to notify — keep it cheap, and do not mutate the DOM from it.

Two things the runtime deliberately does not do:

  • It never fabricates an event. It holds no preedit on your behalf, so a focus change is not an ImeEvent::Disabled. When another target claims the keyboard the window’s input method may stay enabled throughout and nothing ends your composition — clear the preedit in on_focus_lost, as above.
  • ImeEvent::DeleteSurrounding is inert on desktop today. Rinch asks winit only for cursor-area support, so no desktop backend advertises surrounding-text and the variant never arrives. Android’s InputConnection does send it.

Everything under Window focus applies: while the window is blurred, IME reports disabled and no composition is routed, but the claim — and on_ime with it — comes back on refocus.

The dismiss stack

Escape is the one key an overlay has to answer without holding the keyboard: a Modal is dismissed by Escape whether the focus is on a field inside it, on the modal itself, or nowhere at all. That is a different job from both APIs above, and it has its own registry (issue #474).

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

let opened = Signal::new(true);
let handle = push_dismiss_handler(root.doc_key(), move || {
    if opened.get() {
        opened.set(false);
        true            // consumed: the key stops here
    } else {
        false           // not open: pass it to the overlay below, then the app
    }
});
__scope.on_cleanup(move || drop(handle));
}
  • It is a stack, last in first out. The most recently registered handler is asked first, so two nested modals close innermost-first. The first handler that returns true consumes the key and the ones beneath it are never asked.
  • Answer false while you are closed. An overlay usually stays mounted when it closes — opened_fn only toggles a class — so the check belongs inside the handler, at dispatch time, not at registration. A handler that always returns true swallows Escape for the rest of the session.
  • It is released on unmount, twice over: the DismissHandle releases it when it drops, and an entry whose owning scope has been disposed is dropped as the scan passes it rather than run (issue #183 — its captured signals are already freed).
  • It is scoped per document, on desktop and embed. Two RinchContexts on one thread do not answer each other’s Escape (issues #134, #139). On rinch-web there is nothing to scope and nothing doing the scoping: a page dispatches with no document marked, and the rule is deliberately permissive when either side is unmarked, so every handler is reached. The explicit key you pass at registration is what carries the distinction where one exists.
  • Both backends, no branch. Dispatch lives inside dispatch_keyboard_event, which desktop calls ahead of the focus arbiter and rinch-web calls from its document keydown listener.

Some things are dispatched before the stack and will take Escape from it: a set_keyboard_interceptor that consumes the key, and an in-progress drag, which Escape cancels. Both apply on either backend. rinch-web has one more — a focused RenderSurface swallows every key there, where on desktop a surface is routed by the arbiter, i.e. after the stack. That asymmetry is older than the stack (the interceptor already had it) and is not something this API changed.

Register at mount, or at open

The snippet above registers once, when the overlay mounts, and answers false while it is closed. That is what Modal, Drawer and Popover do, and it is right for an overlay whose open state arrives as a prop: render runs once, so there is no later moment to hang a registration on.

It is the wrong choice for an overlay statically nested inside another one. A component renders after its children, so Modal { ColorInput { … } } pushes the input’s entry first and the modal’s on top of it — and the stack is LIFO, so the modal answers Escape while the colour picker is the thing on screen.

An overlay that owns its open state avoids that by pushing when it opens and releasing when it closes. Opening happens after every ancestor has mounted, so LIFO puts the entry where the user expects it with no precedence rule anywhere. This is what the native <select> popup does (issue #671) and what ColorInput’s dropdown does (issue #465); in a component, rinch_components::overlay_dismiss::arm_close_on_escape_while_open is that policy written out. Such a handler consumes unconditionally — it exists only while the overlay is open, so it has no closed state to decline in — and the release must cover unmount-while-open as well as close.

Modal, Drawer and Popover already do all of this for you through their close_on_escape prop — reach for push_dismiss_handler when you are building an overlay of your own. Prefer it over set_keyboard_interceptor for Escape: the interceptor is a single slot per document, so a second overlay registering there silently disables the first, and when it unmounts it clears the slot rather than restoring what it displaced.

A popup that owns every key while it is open and focused

#![allow(unused)]
fn main() {
let handle = push_key_handler(
    &root,                                   // the owner: the popup's component root
    move |k: &KeyEventData| k.key == "ArrowDown" && { step(); true },
    move || opened.set(false),               // focus left the owner
);
}

push_key_handler puts an entry on the same stack that is offered every key press (never a release), ahead of the Escape scan; true consumes the key, which on rinch-web also preventDefaults it. It is for a popup whose focus stays on something outside the popup’s list — the Select component’s option list, whose combobox trigger keeps the focus while the arrows move a highlight.

It is offered a key only while the focused element is the owner or inside it. A field focused while the popup is open — by a shortcut, a script, an autofocus — keeps its own keys, and on the web a key typed outside the rinch mount is never touched. When focus moves and is no longer inside the owner, the entry’s on_focus_leave runs (the backends report every focus move), which is where a listbox closes; pass || {} if yours should stay open.

Register it at open and release it at close and on unmount — rinch_components::overlay_dismiss::arm_keys_while_open is that policy. Decline Escape in it and give Escape its own dismiss entry, so “which overlay is on top” stays one question.

When the focused element goes

On the web, a NodeHandle verb that removes or moves the focused element, or an element around it, lets the focus go first: remove, discard, remove_child, replace_with (for either node), set_inner_html and set_text over it, and append_child / insert_before / insert_after of a node that is already in the document. That covers a reactive if hiding a panel, a keyed for moving a row, and unmounting a root. The focused element is blurred, with the document free, and then the node goes. So:

  • a key entry whose owner is in the part that goes gets its on_focus_leave (an open Select in a hidden panel closes). Chrome fires the focus events by itself when a focused node is removed; Firefox fires nothing, and rinch’s own blur() makes the two agree;
  • a modified field in it gets its onchange only while its handler is still alive: a field removed directly, or a captured one a branch hides (if show { {field} }). A field the hidden branch built (if show { input { onchange: … } }) has had its handlers freed before it goes, and commits nothing;
  • a handler those events run may use any NodeHandle, and the node it is about is still where it was. A handler that keeps putting the focus back inside the part that goes is stopped the fourth time: that part is held inert for the last blur, so the focus cannot return to it;
  • a verb the browser refuses (an insert_before whose reference is not a child of the parent, a node appended into itself) moves nothing and keeps the focus;
  • a node moved inside the document keeps the focus where the browser has Node.moveBefore (Chrome 133 and later; check your other target browsers), as it always has on desktop. append_child, insert_before and insert_after of a node that is already in the document — a keyed for moving a row — are then that call: no focus event fires, an open Select in the row stays open and an <iframe> in it is not reloaded (measured in Chrome 153 and Firefox 157); a scroller in it keeps its offset in Chrome and is reset to the top in Firefox 157. A running CSS animation or transition in the row carries on and an IME composition in its <input> is not ended (measured in Chrome 153). One exception to “keeps the focus”: a node moved under a parent where it cannot be focused (display: none, hidden, inert, visibility: hidden, a disabled <fieldset>, a closed <details> or <dialog>) keeps it through the move and the browser blurs it afterwards, so a key entry’s on_focus_leave runs then, once. A page’s own moveBefore polyfill written over insertBefore is recognised and not used. In a browser without the method the move is a removal and an insertion, the moved node loses the focus and the list above applies; call focus() on it again after the move if it must keep the keyboard there. A move into or out of a detached subtree, and replace_with, take that path in every browser.

Three attribute writes can cost the focused element its focus in place: hidden on it, and removing the tabindex or contenteditable that made it focusable (Chrome blurs inside the write; disabled, inert and display: none fire nothing there). set_attribute, write_attribute and remove_attribute make those writes with the document free too. A tabindex write that leaves the element focusable (a roving tabindex) keeps the focus.

Not covered: UpdateBatch::apply, which is handed the document already borrowed, and any other code that calls a DomDocument method directly. A SetText or attribute write there that unfocuses an element still runs the browser’s listeners under that borrow.

Locking the page behind an overlay

The other thing an open dialog owes the page behind it is stillness. Modal and Drawer do it for you through their lock_scroll prop (default on, issue #474); a custom overlay takes the same lock on its own root:

#![allow(unused)]
fn main() {
// On open — the node matters: it names the subtree that stays scrollable.
root.set_scroll_locked(true);
// On close, and again on unmount, or the page never scrolls again.
root.set_scroll_locked(false);
}
  • Locks are counted. Two overlays open and the inner one closing leaves the page locked. Every true must be matched by exactly one false.
  • Release it on unmount as well as on close. An overlay can be taken out of the tree while still open, which is what if show { Modal { .. } } does. The effect that would have unlocked it never runs again, so the release belongs in an on_cleanup too. Modal and Drawer already do both.
  • The two backends do different things, and this is the one overlay behaviour where that is visible. Desktop refuses the gesture: a wheel (which is what a touch scroll arrives as too) and a scrollbar-thumb press are rejected for any container that is not inside a locking overlay, so the dialog’s own overflow: auto body still scrolls. rinch-web sets overflow: hidden on the real <html>, because rinch cannot gate the browser’s own wheel.
  • So web also removes the page’s scrollbar and desktop does not. A web page with a classic (non-overlay) scrollbar shifts sideways when an overlay opens. Desktop’s bars are overlays with no gutter, and a locked page’s bar stays painted — visible, and inert.
  • Input only. Programmatic scrolling (set_scroll_top, a list the app scrolls itself) is untouched on both.
  • A drag already in flight ends when a lock arrives. A scrollbar thumb the user is holding when something else opens a dialog is released rather than left to go on scrolling the page.
  • In island mode the lock is still the whole page. There is one <html>, so a rinch Modal inside an island freezes the host page, not just rinch’s own region — one lock, counted across every island on the page. That is what every web overlay library does, but an embedder should know it before shipping a dialog into someone else’s page.
  • On iOS Safari, overflow: hidden on <html> is not always enough to stop rubber-band scrolling; libraries there add a position: fixed body trick. We have not tested it and rinch does not do it, so treat the web half as “the page stops scrolling in every browser we have measured” rather than as universal.

Do not reach for RenderScope::body_handle() and set overflow: hidden there: on the web that handle is <div id="rinch-body">, a descendant of the real <body>, and styling it does not stop the page scrolling.

A scroll container outside the overlay: data-scroll-lock-exempt

On desktop the lock refuses every scroll container that is not inside a locking overlay’s root. That is the page behind the overlay, which is the point, but it is also any scroll container of yours that sits outside the overlay in the tree and is still meant to be used while it is open: a popup portalled to <body>, a menu hanging from window chrome, a panel that paints over the dialog. Mark it:

#![allow(unused)]
fn main() {
div {
    class: "my-popup-list",
    style: "max-height: 300px; overflow-y: auto;",
    data-scroll-lock-exempt: "",
    // ...
}
}
  • The attribute exempts its subtree, so a popup carries it once on its root and every scroller inside scrolls. On a component, write it the same way: a hyphenated attribute goes on the component’s root element.
  • It exempts everything under the marked node and nothing else: an unmarked scroll container outside the overlay stays locked.
  • It cannot unlock the page. A mark on a node that contains the locking overlay — <body>, an app root, a wrapper around the dialog — is ignored, so the page behind the overlay stays locked whatever its ancestors carry. Mark the popup, not something above it.
  • It is not checked against paint order. A marked scroller that the overlay covers also scrolls when the wheel is over it (the pointer is on the backdrop, and the wheel finds the scroller underneath). Mark only what is shown above the overlay.
  • It is one of rinch’s data- boolean attributes: on unless its value is "false", and a reactive data-scroll-lock-exempt: {|| flag.get()} removes it when flag is false. rinch_core::events::SCROLL_LOCK_EXEMPT_ATTRIBUTE is the name, for code that writes it through a NodeHandle.
  • There is nothing to release: the exemption goes with the node.
  • Desktop only, and the web needs none. The web’s lock is overflow: hidden on <html>, which stops the page and leaves every scroll container inside it scrolling, so the attribute is inert there.

The DOM menu bar (the in-app bar of a Linux window) marks its own dropdowns and submenu flyouts, so a menu too long for the window scrolls while a dialog is open (issue #701). The runtime’s native <select> popup, which it appends to <body>, is exempt the same way through the runtime’s own list (NodeTree::push_scroll_lock_exempt); you do not need to mark a <select>.

Undo and redo in a text field

A focused <input> or <textarea> undoes with Ctrl+Z and redoes with Ctrl+Shift+Z or Ctrl+Y (Cmd on macOS) on desktop, as a browser field does on the web (issue #288).

  • oninput fires with the restored text, exactly as for a keystroke, so a controlled field’s signal follows the undo. The eventual onchange compares what the field shows at the commit with what it showed when focus arrived, so undoing back to the starting text commits nothing.
  • A readonly field refuses both, like every other text-changing command, and a field that goes disabled while focused releases the keyboard instead.
  • One key press is one undo step. Each typed character is its own step — desktop does not merge a run of typing into one the way a browser does. Typing over a selection is one step: the selected text comes back whole.
  • A keystroke and the rewrite its own oninput made are one step. A normalizing field (upper-casing, say) therefore undoes to the text before the keystroke, never to the raw text the handler is about to rewrite again. A keystroke the handler rejects — writes the field back to what it showed, as a digits-only filter does with a letter — leaves no step at all. This is where desktop and the web differ: a browser treats that rewrite as a script write to .value and drops the field’s whole undo history, so a normalizing field has no undo on the web.
  • Any other write to the field’s value clears its undo and redo history — a value_fn reset, a clear after onsubmit, a write from a timer or another handler. It is the app’s change, not the user’s, so Ctrl+Z cannot bring back text the app replaced. That is what a browser does for a script write to .value, so for a write made outside a keystroke desktop and the web agree.
  • Not with Alt, and not mid-composition. Ctrl+Alt+Z is left alone (it is AltGr on Windows, which types a character on some layouts), and Ctrl+Z does nothing while an input method is showing a composition.
  • The built-in right-click menu (below) has no Undo row; the chords are the way in.

Right-clicking a text field

A right press on a text target — an <input> of a text-like type (text, search, url, tel, email, password, number, or no type at all), a <textarea>, or the rich-text Editor — opens a built-in Cut / Copy / Paste / Select all menu on desktop (issue #813). It is a menu the runtime draws itself, as a DOM overlay in the same shape as the native <select> popup, so it is the same on Linux, macOS and Windows, works in a borderless or transparent window, and follows the theme’s palette when there is one.

  • A data-oncontextmenu handler still wins. If the pressed node or any ancestor carries a live one, it runs exactly as before and the built-in menu stays shut. Attach one to a field to give it a menu of your own.
  • Each item runs the code its chord runs — Ctrl+X / Ctrl+C / Ctrl+V / Ctrl+A (Cmd on macOS), including the editor’s asynchronous, anchored paste. Today that is literally one function per operation on both paths, and a fixture pins item and chord to the same value, selection and clipboard; nothing in the code enforces it, so a check added to one path alone would drift the other silently.
  • Enabled states follow the field. Cut and Copy need a non-empty selection; a readonly field greys Cut and Paste; a password field greys Cut and Copy; Select all needs content; a disabled field takes no focus and gets no menu. Without the clipboard feature Cut, Copy and Paste are all greyed — there is no clipboard for them to reach, and an enabled Cut would delete text it never copied. Paste is not decided by reading the clipboard — a read can block for up to four seconds on X11 — so it stays enabled over an empty clipboard, where a native menu would grey it out; pasting nothing does nothing.
  • The press places the caret like a native editor’s. Outside the current selection it moves the caret to the press point; inside it, the selection is kept, so Cut and Copy act on what you selected.
  • The field keeps the keyboard while the menu is open and after an item runs. Up / Down / Home / End move over the enabled rows (wrapping), Enter or Space runs the highlighted one, and Escape closes through the dismiss stack — the menu pushes its entry at open time, above an enclosing Modal’s, and releases it on close. Any press outside the menu closes it without acting and is swallowed; a wheel closes it too.
  • It goes with its target. The menu closes when the field’s claim moves (a click elsewhere, a programmatic focus), when the window loses focus, and when the field leaves the document — the last is checked on every event rather than waiting for a second event that might never arrive.

On rinch-web the browser’s own menu covers text fields and the runtime draws none (issue #812). On Android a long press in a field arrives as the same right press; which presentation the Android shell uses is that platform’s half of #813 — the runtime exposes the state and the actions (RinchApp::text_edit_state, perform_text_edit) for a native toolbar to build on, with this menu as the fallback.

Where this does not apply

  • The browser backend (rinch-web). There is no arbiter there because the browser is one: register_focus_target is a desktop / Android / embed API. On web, give the element a real tabindex and use the DOM’s own focus, blur and keydown events. data-trap-focus is the exception — it works on both backends, because it is a contract the backend reads rather than a registration against the arbiter.
  • The document-level keyboard hook. set_keyboard_interceptor is a capture-phase hook for the whole document, dispatched before the arbiter and regardless of focus. It is for global shortcuts; on_key is for a focused widget. They are different jobs and both still exist. It routes per document (issue #340): a hook registered while a document’s code is running — its event dispatch, its mount (issue #295), or an effect it owns, wherever that effect happens to be flushed — intercepts only that document’s keys, and one registered from main is the thread-global fallback that intercepts for every document without its own — so two windows that each register at mount or from their own event handling no longer clobber each other. Registrations made outside any document (from main, a timer or run_on_main_thread closure armed there, or on rinch-web, which marks no document) still share the single fallback slot, last-wins. A timer, run_on_main_thread closure or http/ws completion armed inside a document runs as that document’s code (issue #963), so what it registers or clears is that document’s. A document is served whichever of its own hook and the fallback was registered later, so a single-window app keeps last-registration-wins wherever it registers from; clear_keyboard_interceptor from outside any document clears every entry, and from inside one leaves that document with none. The cost with several documents on one thread (two embedded contexts, an app and its DevTools): code outside any document — main, a timer armed there, a closure queued from a worker thread, a native menu or tray callback — cannot say which document it belongs to, so its registration overrides every document’s own hook and its clear wipes every document’s. Registrations made inside documents stay isolated from each other. Its lifetime does match the arbiter’s, though: registering it during a render releases it when that component unmounts, exactly as a FocusEntry is deregistered (issue #183). Registering it from main keeps app lifetime. For Escape, use the dismiss stack instead — one slot cannot nest.
  • IME on the browser backend. on_ime is desktop / Android / embed only, like the rest of this API. On web, attach compositionstart / compositionupdate / compositionend to your element yourself — the browser delivers composition to whatever it considers focused.
  • The Android soft keyboard. A registered target participates in desktop IME, but does not yet raise Android’s on-screen keyboard: the shell still watches for a focused <input> or the rich-text editor.
  • Modality. Tab is contained by an open Modal or Drawer — see Containing Tab inside an overlay — but a click still reaches controls behind the backdrop wherever the backdrop itself does not cover them, and a click or a scripted focus() outside an overlay moves focus out of it. A browser’s showModal() refuses both, because it marks the rest of the page inert; rinch has no inert and models no showModal. DropdownMenu has no trap_focus prop and contains nothing.

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) adds GPU rendering via Vello/wgpu beside the software renderer, chosen at run time
  • "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()
    };
    App::new(app)
        .title("My App")
        .size(800, 600)
        .theme(theme)
        .run();
}

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 — uncontrolled: the component owns the number, so the
// +/- steppers step, clamp to [min, max] and format it themselves,
// reporting each written value through `oninput` (#501)
NumberInput {
    label: "Quantity",
    value: Some(1.0),
    min: Some(0.0),
    step: Some(1.0)
}

// NumberInput — controlled: pass `value_fn` and the signal becomes the
// field's single write path; the steppers stay callback-only, so write
// the signal in `onincrement`/`ondecrement` (#264)
let quantity = Signal::new(1.0);
NumberInput {
    label: "Quantity",
    value_fn: move || quantity.get().to_string(),
    min: Some(0.0),
    step: Some(1.0),
    onincrement: move || quantity.update(|v| *v += 1.0),
    ondecrement: move || quantity.update(|v| *v = (*v - 1.0).max(0.0))
}

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

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

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

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

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

// Slider
Slider { color: "cyan" }

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

Typography

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

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

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

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

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

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

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

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

Feedback

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

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

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

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

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

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

Data Display

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

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

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

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

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

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

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

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

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

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

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

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

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

// Stepper. Each step's state follows its position against `active`, and
// `{|| …}` makes the whole stepper follow the signal.
let step = Signal::new(1u32);
Stepper { active: {|| step.get()},
    StepperStep { label: "Account", description: "Create account" }
    StepperStep { label: "Verify", description: "Verify email" }
    StepperStep { label: "Complete", description: "Get started" }
}
}

Overlays

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

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

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

An overlay that animates must stay rendered. Hide it with visibility or opacity and animate transform; do not toggle display. An element that was not being rendered has no before-change style, so un-hiding it and retargeting a transitioned property in the same style pass animates nothing — on desktop and in the browser, which refuses the same shape (that is what @starting-style and transition-behavior: allow-discrete exist for). Drawer and Popover are the two worked examples: both keep their panel rendered while closed, Drawer slides its transform and Popover fades its opacity. See the transitions section of the rendering pipeline.

To animate the close as well, delay the hide. Put transition: visibility 0s linear <duration> on the hidden state only: the box stays visible through the transition’s delay, then hides, and the shown state — which declares no visibility transition — cancels a pending hide if the overlay reopens first. Drawer and Popover both do this. visibility is transitionable on desktop since issue #759, interpolated as CSS says (every step strictly inside a transition with a visible end is visible), and unlike any other property there its animated value reaches the descendants that inherit it, so the panel’s content stays on screen with it.

A visibility: hidden subtree is excluded from paint, from hit testing and from the Tab order, which is most of what display: none was doing — but not all of it, and the difference is that the box is still there. Two consequences to know before you reach for it are below.

On desktop, “excluded from paint” covers the box’s background, border and shadow, and also its text, underline (wavy too) and line-through, text-shadow, inline backgrounds, SVG shapes, scrollbar thumbs, RenderSurface frame, filter overlay and read-only selection highlight (desktop drew the text until issue #829). Three known exceptions remain: a hidden span inside a nowrap or pre root that text-overflow: ellipsis cuts is still drawn (#853; a wrapping root holding one is clipped without a “…” instead), a hidden data-viewport still cuts its hole in its ancestors’ backgrounds (#854), and a ligature that straddles a visibility boundary follows the wrong end of it (#852). As in CSS, a descendant that declares visibility: visible is painted again.

  • It still takes part in layout, so a hidden overlay that is an in-flow child of an overflow: auto container contributes its box to that container’s scrollable extent, and the container grows a scrollbar for content nobody can see — as it would in a browser. A position: fixed overlay (Modal, Drawer) is exempt, since #765: a fixed box is no part of any scroll range below the viewport.
  • A descendant may declare visibility: visible, and the three readers do not agree about what that means. Paint honours it (the node is drawn), the Tab order honours it (collect_focusable_nodes_from tests each node on its own), and hit testing does not — it rejects the whole subtree at the hidden ancestor before it ever reaches the descendant. So such a node is painted and tabbable but not clickable (#843).

Neither is a reason to go back to display, which cannot animate at all. They are the two places to look when a closed overlay misbehaves.

#![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 — the `position: relative` on the container is REQUIRED.
// Without it the overlay's containing block is the nearest positioned ancestor
// further up — or the viewport, and it covers the whole window (the CSS rule,
// and what rinch-web has always done).
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()
    };

    App::new(app).window_props(window_props).run();
}

BorderlessWindow Props:

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

The component provides:

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

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

Building Custom Components

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

Building Custom Component Crates (Advanced)

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

1. Create the Crate

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

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

2. Implement the Component Trait

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

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

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

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

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

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

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

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

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

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

        card
    }
}
}

3. Add Event Handlers

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

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

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

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

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

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

        button
    }
}
}

invoke runs the app’s handler untracked, so it is safe to call from inside one of your component’s effects too: whatever signals the handler reads, your effect is not subscribed to them. See Reactivity.

4. Create CSS Styles

Create a styles module that generates CSS for your components:

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

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

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

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

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

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

5. Export Your Components

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

mod card;
mod button;

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

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

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

6. Use Theme CSS Variables

Leverage Rinch’s theme variables for consistency:

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

7. Use Your Component Crate

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

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

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

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 built on Tabler Icons. Instead of passing arbitrary HTML strings, components accept TablerIcon enum values for discoverability and consistency.

TablerIcon lives in the rinch-tabler-icons crate and is not re-exported through the rinch prelude, so add it alongside rinch:

rinch-tabler-icons = { workspace = true }

Older snippets may show a curated Icon enum from rinch-core. That enum no longer exists — every icon prop takes Option<TablerIcon>. A few names were spelled differently: Icon::CheckCircle is now TablerIcon::CircleCheck, and Icon::XCircle is TablerIcon::CircleX.

Basic Usage

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

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

        Alert { icon: TablerIcon::CircleCheck, color: "green", title: "Success",
            "Operation completed successfully."
        }

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

        Alert { icon: TablerIcon::CircleX, color: "red", title: "Error",
            "Something went wrong."
        }
    }
}
}

Available Icons

All 4,983 Tabler icons are available as enum variants — browse them at tabler.io/icons, or iterate ALL_ICONS (with ICON_COUNT) to build a picker. A sample of the common ones:

CategoryIcons
NavigationHome, ChevronUp, ChevronDown, ArrowLeft, ArrowRight, Menu2
ActionsPlus, Minus, X, Check, Search, Settings, Edit, Trash
Status/AlertsInfoCircle, CircleCheck, AlertCircle, AlertTriangle, CircleX
ContentUser, Mail, Phone, File, Folder, Photo, Copy
UIBell, Message, Send, Share, Download, Upload

Every icon has an Outline form; roughly a thousand also have real Filled artwork, and asking for TablerIconStyle::Filled on one that doesn’t falls back to its outline paths. See Icon System in CLAUDE.md for rendering icons standalone with render_tabler_icon.

Components with Icon Support

Every prop below is Option<TablerIcon>; rsx! supplies the Some(...), so write icon: TablerIcon::Check, not icon: Some(TablerIcon::Check).

ComponentIcon Props
ActionIconicon
Alerticon
Notificationicon
AccordionControlicon
Blockquoteicon
Listicon
ListItemicon
Steppercompleted_icon, progress_icon
StepperStepicon, completed_icon, progress_icon
NavLinkleft_section, right_section
DropdownMenuItemleft_section, right_section
Tableft_section, right_section

Tree takes its icons through data instead: TreeNodeData::icon, set with the with_icon(TablerIcon) builder.

List::icon and the two Stepper icons are container defaults for the items that set none — the item’s own icon always wins — and were declared but wired to nothing until #707. The container patches its items after they have rendered, because a parent component renders after its children, and it patches whatever lands beneath it afterwards too: an item a for reconcile appends, a show_dom branch reveals, or a hand-rolled append_child adds takes the default as it lands (issue #716). An item moved from one container into another re-resolves against the one it now belongs to. Stepper goes one further and re-derives when an item leaves as well (issue #745), because its answer is a step’s position: dropping a step, or moving one out, renumbers and restates every step behind it. Stepper::progress_icon stands in for the progress_icon a step did not set, so it outranks that step’s plain icon, exactly as the step’s own progress_icon would have.

Examples

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

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

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

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

// Stepper with custom icons. Each step's state comes from its position
// against `active`, so "Account" is completed and draws the CircleCheck,
// "Verify" is in progress and draws `2`, and "Complete" draws `3`.
Stepper { active: 1, completed_icon: TablerIcon::CircleCheck,
    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:, style shorthands, hyphenated attributes | Yes | Surgical DOM update (also on a component that re-renders for a struct prop) | | 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
        },
    }
}
}

The echo is not written back. In this pattern every keystroke runs oninput → signal → value_fn, which returns the very text the field already shows. TextInput, PasswordInput, Textarea and NumberInput compare value_fn’s result with the field’s live text (NodeHandle::live_value, issue #238) and write only when they differ — a normalising handler’s rewrite, or a programmatic change. The comparison is against the live text, not the value attribute, which on the web still holds the last programmatic write while the user types.

Writing to the field the user is typing in. A value_fn write (or any set_attribute("value")) that lands on the focused field is adopted by the field on both backends (issue #238): it becomes the text the next keystroke edits, the caret keeps its logical position through the rewrite (a kept prefix or suffix keeps the caret with it, a same-length rewrite such as a case change leaves it in place, a resized rewrite puts it after the new text), a selection survives, and the write is deferred while an IME composition is in flight (applied when the composition ends). A programmatic write never commits onchange by itself — only a user edit in the gesture does. So a normalizing or rejecting oninput is safe to write back on every keystroke:

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

rsx! {
    TextInput {
        value_fn: move || digits.get(),
        // Digits only: the rejected character never sticks, and the caret
        // stays put — on desktop as well as on the web.
        oninput: move |value: String| {
            digits.set(value.chars().filter(char::is_ascii_digit).collect())
        },
    }
}
}

Text inputs (TextInput, Textarea, PasswordInput, NumberInput) also take an onchange prop — the commit boundary (issue #226): it fires once with the final value when the typed gesture ends (focus leaves the control after a modification, or Enter — blur-only for Textarea), and only if the user modified it during the gesture — a programmatic write to a still-untouched field moves the reference point with it, matching the browser’s dirty flag. Keep oninput for the controlled-input signal sync; use onchange for validate-on-commit and autosave-on-leave.

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
App::new(app)
    .title("My App")
    .size(800, 600)
    .theme(theme)
    .run();
}

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.

Keeping this page honest: this file is hand-maintained (prose, worked examples, and “Custom Default” callouts don’t survive mechanical regeneration), so it will drift from the component structs again. Before trusting or editing a prop table, run python3 scripts/audit_component_props_doc.py from the repo root — it cross-references every row here against the real pub struct fields in crates/rinch-components/src/*.rs and prints every type mismatch, missing row, and stray row it finds (see the script’s own docstring for the handful of known non-issues it also reports, e.g. Callback vs a fully-qualified rinch_core::Callback spelling that source uses in a few files — same type, not a bug).

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}. So does any hyphenated attribute — data-nofocus: "", aria-label: "…" — which is written on the root the way it would be on an HTML element; see Attributes on a component.

Both merge with what the component itself put on that root rather than replacing it: class: is added to the component’s own classes, and style: is laid over the component’s own inline declarations, last-wins per property. That matters because a component’s inline style is not decoration — it is how a component publishes a prop to its own stylesheet, and every overlay’s z_index is a custom property written there. style: used to write the whole attribute, which erased all of them (issue #647).

A reactive style: takes its own previous declarations back on each re-run, so it never accumulates: a property the closure has stopped naming is removed, or restored to the value it had been overriding. It overwrites a property it still names where that property stands rather than re-appending it, and it leaves alone one whose current value somebody else has since written. The one case it does not settle is a same-property collision with a style shorthand — see Application Order.

A reactive class: has the same memory: it takes off the words it last wrote before adding the new ones, so it never accumulates its own old values. It does not reach a class it has never named — but naming one is enough to own it. A word the closure emits is the closure’s to remove, so a re-run that stops naming it takes it off and nothing puts it back, and that holds for a class the component itself wrote, its base class included: class: {|| if p.get() { "rinch-checkbox marker" } else { "marker" }} strips rinch-checkbox off the element permanently. That is issue #647’s mechanism and the rule the reactive style: follows too — don’t name a class the component owns.

The merge only holds if the component keeps its side of it. A class: prop is applied to the handle Component::render returned, which is after the component has built its tree and registered its effects. A component effect that then rewrote the whole class attribute — from a base string captured during render — therefore discarded the caller’s class on its first run, and there was no way to get it back short of a re-render. Checkbox { class: "compact", checked_fn: … } styled correctly until the user clicked it once (issue #717). So a component effect adds and removes the one class it is responsible for (NodeHandle::add_class / remove_class) and never writes class wholesale. add_class is idempotent, as DOMTokenList.add is, so an effect that re-runs without changing its answer leaves the attribute alone.

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

A value_fn write to the text field that currently holds focus is adopted by the field (issue #238): the next keystroke edits the written text, the caret and selection keep their logical position through the rewrite, the write is deferred during an IME composition, and it never commits onchange by itself. See Controlled Input Pattern.


Layout

Stack

Vertical flex container.

PropTypeDefaultDescription
gapString""Spacing between children (xs, sm, md, lg, xl or CSS value)
alignString""CSS align-items (e.g., “center”, “flex-start”)
justifyString""CSS justify-content

Group

Horizontal flex container.

PropTypeDefaultDescription
gapString""Spacing between children (xs, sm, md, lg, xl or CSS value)
alignString""CSS align-items
justifyString""CSS justify-content
wrapboolfalseEnable flex-wrap
growboolfalseChildren flex-grow: 1

SimpleGrid

Auto-layout grid.

PropTypeDefaultDescription
colsOption<u32>NoneNumber of columns (default 1)
min_child_widthString""Min column width for auto-fill; overrides cols
spacingString""Gap between items (xs, sm, md, lg, xl or CSS value)
vertical_spacingString""Vertical gap (xs, sm, md, lg, xl or CSS value); falls back to spacing

Container

Centered max-width wrapper.

PropTypeDefaultDescription
sizeString""Max-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
wString""Width (spacing scale or CSS value)
hString""Height (spacing scale or CSS value)

Buttons

Button

PropTypeDefaultDescription
variantString""“filled”, “outline”, “light”, “subtle”, “transparent”, “white”, “default”, “gradient”
sizeString""xs, sm, md, lg, xl
colorString""Theme color name
disabledboolfalse
loadingboolfalse
full_widthboolfalse
radiusString""Border 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
variantString""Same variants as Button
sizeString""xs, sm, md, lg, xl
colorString""Theme color name
radiusString""
disabledboolfalse
loadingboolfalse
onclickOption<Callback>NoneClick handler

CloseButton

Draws a stroked X, sized by icon_size or by size. Until issue #474 it drew nothing at all: the glyph was an empty <span class="rinch-icon rinch-icon--close"> and no stylesheet in the workspace matches that class.

PropTypeDefaultDescription
sizeString""xs, sm, md, lg, xl
radiusString""
disabledboolfalse
icon_sizeOption<u32>NoneThe X’s edge length in px. Unset, it follows size: 12 / 14 / 16 / 20 / 24 for xs…xl
onclickOption<Callback>NoneClick handler

Form Inputs

TextInput

PropTypeDefaultDescription
labelString""
placeholderString""
descriptionString""Help text below input
errorString""Error message; shows error styling
sizeString""xs, sm, md, lg, xl
disabledboolfalse
requiredboolfalse
radiusString""One of xs sm md lg xl, rounding the field. Empty keeps the theme’s default radius
input_typeString""HTML input type (“text”, “email”, etc.)
valueString""Static value
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
oninputOption<InputCallback>NoneReceives String
onchangeOption<InputCallback>NoneCommit boundary (#226): fires once with the final value when the gesture ends (blur after a modification, or Enter); only if the value changed since focus
onsubmitOption<Callback>NoneFires on Enter key

Textarea

PropTypeDefaultDescription
labelString""
descriptionString""
errorString""
placeholderString""
sizeString""
disabledboolfalse
requiredboolfalse
autosizeboolfalseGrows rows to the controlled value’s \n-line count, clamped to [min_rows, max_rows] (issue #715). Reactive only through value_fn
min_rowsOption<u32>NoneVisible rows; sizes the control to that many lines. Defaults to 2 (HTML default) when unset. A larger CSS min-height wins
max_rowsOption<u32>NoneCaps the control’s height at that many lines via max-height (issue #715, fixed: the control’s rows is a Taffy measure, not a min-height, so the cap can bind). The sheet’s 60–120px size-step min-height still wins when it is taller than the cap — ordinary CSS min over max, not a bug
valueString""
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
oninputOption<InputCallback>NoneReceives String
onchangeOption<InputCallback>NoneCommit boundary (#226): fires once with the final value when the gesture ends (blur after a modification); only if the value changed since focus

PasswordInput

Custom Default: toggle_visibility defaults to true.

PropTypeDefaultDescription
labelString""
descriptionString""
errorString""
placeholderString""
valueString""
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
visibleboolfalsePassword visibility state
visible_fnOption<ReactiveBool>NoneReactive visibility (auto-wrapped)
disabledboolfalse
requiredboolfalse
autofocusboolfalse
sizeString""
radiusString""One of xs sm md lg xl, rounding the field. The visibility toggle inside keeps its own radius
toggle_visibilitybooltrueShow/hide the eye toggle button
ontoggleOption<Callback>NoneFires when visibility toggled
oninputOption<InputCallback>NoneReceives String
onchangeOption<InputCallback>NoneCommit boundary (#226): fires once with the final value when the gesture ends (blur after a modification, or Enter); only if the value changed since focus

NumberInput

PropTypeDefaultDescription
labelString""
descriptionString""
errorString""
placeholderString""
valueOption<f64>NoneInitial value; uncontrolled, the steppers move the field from here
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped); when set, it is the field’s single write path and the steppers are callback-only
default_valueOption<f64>NoneInitial value of an uncontrolled field when value is absent
minOption<f64>None
maxOption<f64>None
stepOption<f64>None
decimal_scaleOption<u32>NoneNumber of decimal places
prefixString""e.g., “$”
suffixString""e.g., “kg”
disabledboolfalse
hide_controlsboolfalseHide +/- buttons
requiredboolfalse
sizeString""
radiusString""One of xs sm md lg xl. Rounds the field, a prefix/suffix and the steppers together
onincrementOption<Callback>NoneNotification; uncontrolled, the component steps the field itself and reports through oninput (#501)
ondecrementOption<Callback>NoneNotification (see onincrement)
oninputOption<InputCallback>NoneReceives String from direct text entry, and — uncontrolled — each stepper-written value
onchangeOption<InputCallback>NoneCommit boundary (#226): fires once with the final value when the gesture ends (blur after a modification, or Enter); only if the value changed since focus

Checkbox

PropTypeDefaultDescription
labelString""
descriptionString""Secondary line under the label (#707). Puts both in a .rinch-checkbox__body column and aligns the box to the first line
sizeString""
disabledboolfalse
checkedboolfalseStatic checked state
checked_fnOption<ReactiveBool>NoneReactive checked binding (auto-wrapped)
indeterminateboolfalse
onchangeOption<Callback>None

Switch

PropTypeDefaultDescription
labelString""
descriptionString""Secondary line under the label (#707). Puts both in a .rinch-switch__body column and aligns the track to the first line
sizeString""
disabledboolfalse
checkedboolfalse
checked_fnOption<ReactiveBool>NoneReactive checked binding (auto-wrapped)
label_positionString"""start" puts the label before the track; anything else leaves it after
onchangeOption<Callback>None

Select

PropTypeDefaultDescription
labelString""
descriptionString""
errorString""
placeholderString""
sizeString""
disabledboolfalse
requiredboolfalse
valueString""
value_fnOption<ReactiveString>NoneReactive value binding (auto-wrapped)
onchangeOption<InputCallback>NoneReceives selected value as String
dataVec<SelectOption>[]The list of selectable options

Keyboard (#251, #434). The trigger is focusable; Enter or Space opens the list, and while it is open ArrowUp/ArrowDown move the highlight (wrapping), Home/End jump, Enter commits (Space too, unless it is part of a type-ahead prefix), Escape closes without committing, Tab closes, and typing jumps to the first matching label. The trigger keeps the focus throughout, a pointer pick included; focus moving anywhere else closes the list.

Options are passed via data, not as children — the trigger’s _children param is unused. Build a Vec<SelectOption> with SelectOption::new(value, label) (a SelectOption is { value: String, label: String }; an empty label falls back to displaying value):

#![allow(unused)]
fn main() {
Select {
    data: vec![
        SelectOption::new("us", "United States"),
        SelectOption::new("ca", "Canada"),
    ],
    value_fn: move || country.get(),
    onchange: move |v: String| country.set(v),
}
}

The trigger is a Tab stop (tabindex="0", role="combobox", aria-haspopup="listbox", and an aria-expanded that tracks the open state). Enter and Space on it toggle the dropdown; the open list’s keys are above.

Radio / RadioGroup

Radio:

PropTypeDefaultDescription
nameString""Radio group name
valueString""Radio value
labelString""
descriptionString""
checkedboolfalse
checked_fnOption<ReactiveBool>NoneReactive checked binding (auto-wrapped)
disabledboolfalse
sizeString""
colorString""
errorboolfalse
onchangeOption<Callback>None

RadioGroup:

PropTypeDefaultDescription
labelString""
descriptionString""
errorString""
sizeString""Default size for this group’s radios (#707); a Radio’s own size wins, and a radio records its ask as data-size so an explicit "md" is not mistaken for an unset one. A radio that arrives later, or moves in from another group, is sized as it lands (#716)
orientationString""“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
sizeString""
colorString""
radiusString""
disabledboolfalse
labelString""Tooltip 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

onchange reports author edits only. A value arriving through value_fn — a peer’s edit, a programmatic set — is adopted silently: the picker applies all four of its internal components (hue, saturation, value, alpha) as one batched transition and fires nothing, because the caller already has that value. Only a drag, a typed hex or a swatch click reports — once per act, with the completed colour, never a partially-applied mixture. This is what lets a consumer bind value_fn to the same store onchange writes without the two chasing each other.

The picker’s state survives its own round trip. An echoing binding — a store that value_fn reads and onchange writes back into — no longer degrades the picker’s hue, saturation, or alpha. Emitted colour strings quantize — to 8-bit RGB under the hex and rgb formats, to whole degrees and percents under hsl — so the echo of the picker’s own emission routinely parses to a slightly different hue/saturation than the internal state it was formatted from (and to no hue at grey, or no alpha under an alpha-less format like hex); the picker recognises such an echo as its own state and leaves the internal signals untouched, so dragging saturation down to grey and back resumes the original hue, and the alpha slider works under the default hex format. A genuinely foreign value (one that denotes a different colour, alpha included) still applies — and even then, channels the value cannot carry are kept rather than fabricated: a grey keeps the current hue (unless it states one, as any hsl() value does — hsl(240, 0%, 50%), the sub-percent hsl(205, 0.3%, 49%), and hsl(0, 0%, 50%) alike — a stated hue is adopted; note that a store which re-spells an RGB grey as hsl() writes hue 0, and the picker adopts it), a black keeps hue and saturation, judged by what the value renders at 8-bit rather than by exact parse floats. One corner is deliberately conceded: under an alpha-dropping display format, an inbound value that restates the picker’s current RGB with an explicitly opaque alpha (rgba(r, g, b, 1)) is indistinguishable from a normalizing store’s echo of the emission and does not apply — bind an alpha-carrying format (hexa, rgba, hsla) when external writes need to drive alpha. More generally, the echo test judges identity at the resolution of the picker’s own emission in the notation the inbound value is written in — 8-bit channels for a hex, named or rgb() value, whole degrees and whole percents for an hsl() value, alpha at two decimals under rgb()/hsl() — never finer than that notation’s serializer writes, whatever finer channels the parser accepts. A difference the emission could not have spelled folds as an echo, in both directions: on an hsl-format wire a stated-hue move of a whole degree applies even at very low chroma, where it does not move the rendered colour by an 8-bit step, while an inbound hsl() value that shares a spelling with the picker’s current colour folds even where 8-bit could tell them apart (a normalizing store re-spelling the picker’s own hex emission as hsl() is indistinguishable from it). A store that re-spells the emission in another notation may also round a tie its own way — #797e81’s hue is exactly 202.5°, which rinch writes as 202 and an exact converter as 203 — so an inbound value compared against what the picker emits (not what it holds) is accepted at either rounding of the emission’s exact value, and at nothing farther: #797e82’s hue is 206⅔°, so a peer’s hsl(206, …) under a hex display still applies. The text field’s write-back guard judges at the same resolution, so field, thumbs and store stay in step.

Accepted colour notations (everywhere a colour string is read — value, value_fn, typed text, swatches): hex in 3, 4, 6, or 8 digits (#rgb, #rgba, #rrggbb, #rrggbbaa, with or without the #), rgb()/rgba() and hsl()/hsla() in both the legacy comma syntax and the modern space-separated syntax (rgb(51 51 102 / 0.5); alpha as a number or a percentage), and the CSS named colours (red, rebeccapurple, transparent). Named colours and function names are case-insensitive, as in CSS. Out-of-range channels clamp to their CSS ranges; hue wraps (#243).

The text field is the author’s while they type. The hex field parses on every keystroke, and a valid prefix of the colour being typed (#336 on the way to #3366cc) is already a parseable colour — it updates the preview and reports through onchange live, but the picker never rewrites the field under the author’s caret while its text denotes the colour the picker already holds. The field text is normalized to the output format when the colour actually moves away from it (a drag, a swatch click, an external value_fn change) — and, since issue #226, when the typed gesture commits (focus leaves the field, or Enter): a committed shorthand like 336 normalizes to #333366, and committed text that parses as no colour reverts to the colour the picker holds, so an attribute-reading consumer never sees a stale shorthand outlive the gesture that typed it. ColorInput’s text field follows the same contract.

“The field’s text” here is the field’s live text, read through NodeHandle::live_value (issue #238) — on the web the .value property the user types into, never the value attribute, which there holds only what was last written programmatically. It is the same read the value_fn binding of TextInput, PasswordInput, Textarea and NumberInput makes before writing, so the echo of a keystroke is not written back there either.

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""Field size: xs, sm, md, lg, xl. Unset or unrecognised means md. Scales the field’s height, padding and font size, and the preview swatch alongside them
radiusString""Field border radius: xs, sm, md, lg, xl. Unset or unrecognised leaves the default sm
disabledboolfalseDisable the input
valueString""Current color value
value_fnOption<ReactiveString>NoneReactive value binding
onchangeOption<InputCallback>NoneFires the formatted color string for every change made in the dropdown picker — a swatch pick, and each frame of a panel or slider drag — and for a typed edit once, at its commit boundary: blur or Enter (#226). Typing previews live in the swatch and the picker but reports only on commit
formatString"hex"Output format
alphaboolfalseShow alpha slider in picker
swatchesVec<String>[]Preset swatch colors
swatches_per_rowOption<usize>7Swatches per row
disallow_inputboolfalseDisallow typing (picker only)
close_on_click_outsidebooltrueDismiss the dropdown when a click lands outside it (#465). Off mounts no backdrop at all, so no click outside the field dismisses it — the pointer half of the pre-#465 behaviour. Escape closes the dropdown either way

size and radius were declared but unread until #263. size always resolves to a step, radius does not: an unrecognised size falls back to md — matching TextInput, whose size parses with unwrap_or_default() — and md reproduces the geometry the field was hard-coded to before, so nothing resizes by upgrading. An unrecognised radius emits no class at all and leaves the base --rinch-radius-sm standing, which is what DropdownMenu, Modal and Card do.

The dropdown is dismissed three ways (#465): clicking the field again, clicking anywhere outside it, and Escape. The outside click is the one that is optional — close_on_click_outside is true by default, matching every other popover in the library, and turning it off mounts no backdrop at all, so the field is the only thing a click dismisses the dropdown with. Escape is not gated by it, so false is the pointer half of the behaviour ColorInput had until #465 rather than a return to it in full. (The prop was declared and read by nothing before that; #263 removed it rather than wire it, because unlike size and radius there was no behaviour behind it to connect, and it came back here with the interaction it names.)

Two details follow from what the trigger is. The field stays clickable while the picker is open — it is a text input, not a button, so a click into it places a caret rather than being swallowed by the dismissal, and it still toggles the dropdown shut. And Escape joins the dismiss stack when the dropdown opens, not when the input mounts, so a ColorInput inside a Modal answers Escape first while its picker is open and leaves the key to the modal the moment it is closed.

The dropdown picker is bound to the input’s current colour (#237). Typed text previews in it live (a parseable keystroke moves its panel and thumbs, at the typed notation’s own grid — a typed hsl(205, 3%, 49%) lands the hue thumb on 205° whatever the display format, and a typed #228be680 moves the alpha thumb), an external value_fn change moves it, and a slider nudge derives from the colour the input currently holds — never from the colour it mounted with. An external arrival is silent all the way through: the picker applies it without reporting, and the input’s onchange fires nothing the caller did not author.

The field shows the colour in the format output spelling. A value or value_fn written in another notation (red, hsl(200, 3%, 49%)) is displayed re-spelled (#ff0000, #797e81 under hex); the field is rewritten only when the colour moves away from its text, so the author’s mid-typing text is left alone as before, and an unparseable commit reverts it to the last committed colour in that same spelling. Under an alpha-dropping format (hex, rgb, hsl) an alpha typed into the field previews in the picker but is dropped at the commit boundary, and an alpha arriving through value_fn is only half-honoured: the dropdown picker adopts it (its alpha thumb moves when alpha is on) and the swatch renders it translucent, but the field cannot spell it and every change the input reports drops it. The corner ColorPicker concedes above applies here too: a later value_fn value restating the colour opaque (#ff0000, rgba(255, 0, 0, 1)) is indistinguishable from the input’s own echo and does not apply, so the picker keeps the alpha — and the swatch snaps opaque on the next reported change while the picker’s alpha thumb does not — until a value carrying a different, non-opaque alpha arrives. Bind hexa/rgba/hsla when alpha must be externally drivable.


Typography

Text

PropTypeDefaultDescription
sizeString""xs, sm, md, lg, xl
weightString""CSS font-weight
colorString""Theme color or “dimmed”
alignString""CSS text-align
inlineboolfalseUse <span> instead of <p>

Title

PropTypeDefaultDescription
orderOption<u8>NoneHeading level 1-6
alignString""CSS text-align
sizeString""Override size independent of order

Code

PropTypeDefaultDescription
blockboolfalseBlock display (<pre>) vs inline (<code>)
colorString""

Kbd

PropTypeDefaultDescription
sizeString""xs, sm, md, lg, xl

Anchor

PropTypeDefaultDescription
hrefString""
targetString""e.g., “_blank”
sizeString""
underlineboolfalse

Blockquote

PropTypeDefaultDescription
citeString""Citation source
iconOption<TablerIcon>None
colorString""
radiusString""

Mark

PropTypeDefaultDescription
colorString""Highlight background color

Highlight

Custom Default: ignore_case defaults to true.

PropTypeDefaultDescription
textString""Full text to display
highlightString""Substring(s) to highlight
colorString""Highlight color
ignore_casebooltrueCase-insensitive matching

Data Display

Avatar

PropTypeDefaultDescription
srcString""Image URL
altString""
nameString""For initials fallback
sizeString""
radiusString""
colorString""
variantString""“filled”, “light”, “outline”

AvatarGroup: spacing: String — overlap spacing.

Badge

PropTypeDefaultDescription
variantString""“filled”, “light”, “outline”, “dot”, “transparent”, “white”, “default”, “gradient”
sizeString""
colorString""
radiusString""One of xs sm md lg xl. A badge is a pill by default, so this squares it off
full_widthboolfalse

Card

PropTypeDefaultDescription
shadowString""xs, sm, md, lg, xl
paddingString""
radiusString""
with_borderboolfalse

CardSection: inherit_padding: bool, with_border: bool.

Paper

PropTypeDefaultDescription
shadowString""xs, sm, md, lg, xl
pString""Padding (spacing scale)
radiusString""
with_borderboolfalse

Divider

PropTypeDefaultDescription
orientationString""“horizontal” or “vertical”
sizeString""
labelString""Text label in the divider
label_positionString""“left”, “center”, “right”

Fieldset

PropTypeDefaultDescription
legendString""
variantString""“default”, “filled”, “unstyled”
sizeString""
disabledboolfalse

Image

PropTypeDefaultDescription
srcString""Image URL
altString""
widthString""CSS width
heightString""CSS height
fitString""CSS object-fit
radiusString""
fallback_srcString""Fallback image URL
captionString""Caption text below image

List / ListItem

List:

PropTypeDefaultDescription
typeString""“ordered” or “unordered”
sizeString""
spacingString""
centerboolfalseCenter items with icons
iconOption<TablerIcon>NoneDefault icon for this list’s items that set none (#707); a ListItem’s own icon wins, and an item that arrives later, or moves in from another list, takes it as it lands (#716)
with_paddingboolfalse

ListItem: icon: Option<TablerIcon> — per-item icon override, which beats List::icon.

A List renders after its items, so its default cannot reach them as a prop: it finds each item that built no icon layout of its own and rebuilds that item into the same markup ListItem uses, moving the content it already held into the content span.

That patch runs at the list’s own render and again on whatever lands beneath the list afterwards — a for reconcile, a show_dom branch, a hand-rolled append_child (issue #716). The second half rides rinch_core::dom::on_child_inserted, a DOM-level hook the four NodeHandle insertion verbs fire: the container registers against its own root and is told, synchronously, each time a subtree arrives. Synchronously matters — the default is in place before the frame that shows the new item is laid out, so nothing flashes.

The same applies to every container default in the library: RadioGroup::size and the four Stepper props, active included. An item moved from one container into another re-resolves against the one it now belongs to, because a container marks the values it supplied as its own (data-list-icon on a list item) and will not touch a value the child asked for itself.

rinch_core::dom::on_child_removed is the other half, and a container takes it only if its per-item answer depends on a sibling’s position (issue #745). Stepper does — a step that goes moves every step behind it backwards, which renumbers it and can restate it — so it registers both (and on either event re-derives only the steps that moved: one it has already settled at the position it still holds costs three attribute reads, issue #748); List and RadioGroup register only the insertion half, since no row going away can change the icon or the size the next one should have. It is fired by the four verbs that take a node out of a tree (remove_child, remove, discard, and replace_with for the node it displaces) and by the implicit detach an insertion verb performs when handed a node that already has a parent, which is what tells the container a child moved away. set_text on an element that has children and set_inner_html replace the whole child list, and fire it as well (issue #1440). A batched DomUpdate::SetText (UpdateBatch::apply) does not.

Because of that, a step’s state, step and disabled are read when the step arrives or moves, not on every change to the list: change them through the StepperStep props (which re-renders the step), not by writing data-state, data-step or disabled on a mounted step by hand or through a reactive root attribute.

The removal half is handed the node the subtree left — its former parent — rather than the node that went, because a removed node is detached and after a discard may have been retired by the backend. A reorder inside one parent fires the insertion half only: the child set did not change.

The rule a container applies to a nested container is the same one its render-time walk applies downwards, read upwards: every registered ancestor is told about an insertion, and each declines a node whose chain up to it crosses one of its own items. So a List inside another list’s item owns its own rows. A removal is tested against the same chain, starting at the parent it left.

Stepper is the one that has to hand something back down. A step’s state decides which icon it draws, and a step draws its icon before its stepper exists — so a step with no state of its own renders the icons for the states it might be moved into and leaves them in its icon box, hidden, for the stepper to promote (issue #709). Promotion re-parents the glyph out of its wrapper before the wrapper goes, since a discard retires the whole subtree (issue #719).

A glyph the step’s props supplied is parked, not discarded, when the step stops drawing it (issue #716). An insertion moves the steps after it, a removal moves them the other way (issue #745), and a keyed for reorder moves a step in either direction, so a step this stepper put into completed can be displaced back out of it — and its icon is a TablerIcon in props that no patch of the rendered tree could rebuild, so it goes back into a hidden wrapper under the content key it serves. A built-in — the tick, or the step number — is rebuildable from nothing and still leaves by discard. Every parked alternate is kept for as long as the stepper owns the step’s state; a step that named its own state is in that state wherever it moves, so for it they are all dropped.

The markers on a step’s icon box are therefore records of the step’s props rather than of what it last drew, which is what lets the pass re-run: data-icon-has names the content keys the box holds a real glyph for, data-icon-live names the key showing, and data-step-derived on the step says this stepper wrote its data-step rather than the caller.


Feedback

Alert

PropTypeDefaultDescription
colorString""
variantString""“filled”, “light”, “outline”, “transparent”, “white”, “default”
titleString""
radiusString""
with_close_buttonboolfalse
iconOption<TablerIcon>None
oncloseOption<Callback>None

Loader

PropTypeDefaultDescription
typeString""“oval”, “bars”, “dots”
sizeString""
colorString""

Progress

PropTypeDefaultDescription
valueOption<f32>NonePercentage 0-100
value_fnOption<ReactiveF32>NoneReactive value binding (auto-wrapped)
colorString""
sizeString""
radiusString""
stripedboolfalse
animatedboolfalse

Skeleton

Custom Default: animate and visible default to true.

PropTypeDefaultDescription
widthString""
heightString""
radiusString""
circleboolfalse
animatebooltrue
visiblebooltrue

Overlays

z_index on an overlay (issue #474). Modal, Drawer, Popover, DropdownMenu and Notification each paint more than one box, and the boxes have to stay ordered against each other: a modal’s panel above its overlay, a menu’s backdrop below its panel. z_index therefore names the level of the component’s base layer — the one whose stylesheet number the per-component table below quotes — and every other layer keeps its existing offset from it. So Modal { z_index: 500 } is overlay 500 / panel 501, and DropdownMenu { z_index: 500 } is panel 500 / backdrop 499; one number moves the component as a piece. Leaving it unset keeps the stylesheet’s own levels.

Every layer that carries a level is a positioned box — fixed for the Modal/Drawer roots, the DropdownMenu backdrop and the Notification; absolute or relative for the panels inside them — so the level orders it within its nearest ancestor stacking context rather than against the whole page. See the stacking-context notes in CLAUDE.md if a raised overlay still sits under something.

One limit worth knowing: the level is published as an inherited custom property, so an overlay of the same type nested inside a raised one inherits its level instead of falling back to the default — Chromium does exactly the same with the same CSS, and before this prop existed both were equal anyway.

A component-level style: prop is safe to combine with it. It used to replace the root’s whole inline style, which dropped the published level and silently returned the overlay to its stylesheet default (issue #647, fixed); a style: now merges into the root’s own declarations the way class: and the style shorthands always have. See the Universal props note at the top of this page.

Dismissing an overlay (issue #474). close_on_escape, and Popover::close_on_click_outside, and Notification::auto_close were declared and read by nothing until the dismissal PR. All three now work, on desktop and on the web, and all three are requests rather than actions: they invoke onclose, and it is the app that closes the overlay by writing the signal opened_fn reads. An overlay with no onclose has nowhere to send the request, so it registers nothing and leaves the key or the click to the app.

Escape goes through the dismiss stack — a last-in-first-out list every open overlay joins while it is mounted. The innermost open overlay answers the key and the ones beneath it do not, so nesting works; an overlay that is mounted but closed passes the key down rather than swallowing it; and a key nothing takes reaches the app unchanged. Some things beat it, all pre-existing and all correct: a document-level set_keyboard_interceptor, which is the app saying it means to have Escape, and an in-progress drag, which Escape cancels — both on either backend — plus, on rinch-web only, a focused RenderSurface, which swallows every key there. An open native <select> popup takes it too, but by joining this same stack rather than by beating it. A custom overlay of your own joins the same stack with push_dismiss_handler — reach for that rather than an interceptor, which is one slot per document and so cannot nest. When to join it is a real choice and these three overlays make the opposite one from ColorInput’s dropdown and the <select> popup; see Register at mount, or at open.

Outside clicks are caught by an invisible full-viewport backdrop the component renders while it is open, one stacking level under its own panel, so a click on the overlay’s own content still reaches the content. Modal and Drawer use their dimming overlay for this and always have; Popover gained one with the same PR. Each such backdrop carries data-backdrop (rinch_core::events::BACKDROP_ATTRIBUTE), so a press of any button outside dismisses, not only a left one. A custom overlay’s own backdrop needs it too, or a right or middle press outside it is swallowed by the backdrop and does nothing (#1093): a data-rid without it runs for a left press only.

auto_close arms a timer when the notification opens and cancels it if the notification closes — or unmounts — first, so a toast the user dismisses by hand does not fire onclose a second time at its original deadline, and one that is shown again gets a fresh delay. 0 is off.

trap_focus contains Tab inside the overlay while it is open: the component stamps data-trap-focus on its root, and both backends cycle Tab and Shift+Tab within that element instead of walking the page. A click outside is untouched — the pointer is not trapped, as a non-modal dialog’s is not.

It also moves focus into the overlay when it opens and gives it back when it closes, because it is the prop that says the overlay is modal. Modal and Drawer focus their first control (or an autofocus one); Popover moves focus only for an autofocus child, which is the HTML popover API’s rule rather than the dialog’s. Closing restores the element that held the keyboard when the overlay opened, provided that element can still take focus, and unmounting while open does the same. A claim the user had already moved outside the overlay is left alone. Details, the nesting rule and the backend timing are in the focus guide.

lock_scroll holds the page still while the overlay is open, and this is the one overlay behaviour that works differently on the two backends. Desktop refuses the scroll gesture: a wheel (which is also what a touch scroll and the MCP scroll tool arrive as) and a scrollbar-thumb press are rejected for any container that is not inside a locking overlay, so the dialog’s own overflow: auto body still scrolls while the page behind does not. Nothing is restyled, so the page does not move and its scroll position is exactly where the user left it. The web sets overflow: hidden on the real <html>, because rinch cannot gate the browser’s own wheel — which also removes the page’s scrollbar, so a web page with a classic (non-overlay) scrollbar shifts sideways when an overlay opens, and a desktop one does not. That is the whole of the divergence.

Two more things follow from “the gesture, not the style” on desktop, and neither has a web equivalent:

  • The page’s scrollbar is still painted while it is locked. You can see it; it will not move. Nothing is drawn differently, only refused.
  • Only input is gated. Programmatic scrolling — NodeHandle::set_scroll_top, an app scrolling a list behind the dialog on purpose — still works. Desktop has no keyboard page-scrolling (PageDown and the arrows scroll nothing at the document level), so there is nothing to gate there.
  • A scrollbar drag already in flight when the lock arrives is ended, not left to keep scrolling the page while the button is held.
  • The native <select> popup is exempt: its option list is appended to <body>, so it is not inside any overlay’s root, and a long list inside a dialog would otherwise be unscrollable. A scroll container of your own that sits outside the overlay and must keep scrolling while it is open (a popup portalled to <body>, a panel that paints over the dialog) says so with data-scroll-lock-exempt; the DOM menu bar’s dropdowns carry it. See Focus.

On the web the lock is the whole page even in island mode — there is one <html> — so a rinch Modal inside an island freezes its host page too.

Locks are counted: two overlays open and the inner one closing leaves the page locked, and an overlay that unmounts while still open releases its own. A custom overlay of your own takes one with node.set_scroll_locked(true) on its root — the node matters, it is the subtree that stays scrollable — and must release it on close and on unmount.

Tooltip

The open state is the class rinch-tooltip--opened on the root, added and removed by the hover effect. .rinch-tooltip__content is display: none and that class is what reveals it, so styling the open tooltip — or overriding how it appears — is a rule of your own against .rinch-tooltip--opened. Spell it with a child combinator, .rinch-tooltip--opened > .rinch-tooltip__content, as the shipped sheet does: the content is a direct child of the root, and a descendant rule also reaches any tooltip nested inside this one’s target. Before issue #760 the class was emitted and matched by nothing, while the reveal was an inline style rewrite on the content node.

disabled wins over opened: Tooltip { opened: true, disabled: true } shows nothing. Before #760 it showed the content, because the inline reveal outranked the sheet’s .rinch-tooltip--disabled rule.

PropTypeDefaultDescription
labelString""Tooltip text
positionString""“top”, “bottom”, “left”, “right”
colorString""
openedboolfalse
disabledboolfalse
with_arrowboolfalse
multilineboolfalse
widthString""

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

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

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
titleString""
sizeString""
radiusString""
with_overlaybooltrue
overlay_opacityOption<f32>NoneBackdrop alpha, 0-1 (default 0.75). Applies to the dimming overlay, not the panel
overlay_blurString""
centeredboolfalse
close_on_click_outsidebooltrueA click on the dimming overlay invokes onclose
close_on_escapebooltrueEscape invokes onclose while this is the innermost open overlay
with_close_buttonbooltrue
paddingString""
z_indexOption<i32>NoneStacking level of the whole modal: the full-viewport overlay sits here and the panel one above it (defaults 200 / 201)
lock_scrollbooltrueHolds the page still while open. Desktop refuses the gesture, web sets overflow: hidden on <html> — see Overlays
trap_focusbooltrueTab cycles inside the overlay while it is open (#474)
oncloseOption<Callback>None

Drawer

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

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

Opening slides the panel in over 300ms, and closing slides it back out. The closed drawer is visibility: hidden, not display: none (issue #751), which is what lets the slide run: an element that was not being rendered has no before-change style, so a display flip and a transform retarget in one style pass animate nothing — on either backend, since a browser refuses the same shape. See The before-change style, and who has one. Hidden this way the drawer is still excluded from paint — its title and body text included, which desktop drew until issue #829 — from hit testing and from the Tab order. The slide-out keeps the root visible for the 300ms with transition: visibility 0s linear 300ms on the closed state (issues #413, #759), so for those 300ms the closing drawer paints, takes clicks and is reachable by Tab, as it is in a browser; its focus trap is released at the close itself. Reopening part way through cancels the pending hide. An app that wants the old instant close can declare .rinch-drawer__root--hidden { transition: none !important; }.

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
titleString""
positionString""“left”, “right”, “top”, “bottom”
sizeString""
with_overlaybooltrue
overlay_opacityOption<f32>NoneBackdrop alpha, 0-1 (default 0.75). Applies to the dimming overlay, not the panel
close_on_click_outsidebooltrueA click on the dimming overlay invokes onclose
close_on_escapebooltrueEscape invokes onclose while this is the innermost open overlay
with_close_buttonbooltrue
paddingString""
z_indexOption<i32>NoneStacking level of the whole drawer: the overlay sits here and the panel one above it (defaults 200 / 201)
lock_scrollbooltrueHolds the page still while open. Desktop refuses the gesture, web sets overflow: hidden on <html> — see Overlays
trap_focusbooltrueTab cycles inside the overlay while it is open (#474)
oncloseOption<Callback>None

Notification

Custom Default: with_close_button defaults to true.

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
titleString""
colorString""
positionString""Toast position. The top-* variants offset by --rinch-window-top-inset so they clear window chrome
radiusString""
with_close_buttonbooltrue
with_borderboolfalse
iconOption<TablerIcon>None
auto_closeu320Invoke onclose this many ms after it opens (0 = disabled). Cancelled if it closes or unmounts first
loadingboolfalse
z_indexOption<i32>NoneStacking level of the toast (default 300)
oncloseOption<Callback>None

Popover

Custom Default: close_on_click_outside and close_on_escape default to true.

Popover had no close callback and no reactive open state until #474, so neither dismissal prop had anywhere to send a request. Both onclose and opened_fn were added then, matching DropdownMenu; a Popover without onclose still renders, and simply cannot dismiss itself.

PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive open state (auto-wrapped)
positionString""
offsetOption<i32>None
radiusString""
shadowString""
with_arrowboolfalse
arrow_sizeOption<f32>None
arrow_offsetOption<f32>None
close_on_click_outsidebooltrueRenders a dismiss backdrop while open; a click on it invokes onclose
close_on_escapebooltrueEscape invokes onclose while this is the innermost open overlay
widthString""
z_indexOption<i32>NoneStacking level of the dropdown; its click-catching backdrop stays one below (defaults 100 / 99)
trap_focusboolfalseTab cycles inside the popover (target + dropdown) while it is open (#474)
oncloseOption<Callback>NoneInvoked when the popover asks to close

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.

The open state is the class rinch-dropdown-menu--opened on the root, kept in step with opened_fn by an effect. One class reveals both boxes — .rinch-dropdown-menu__dropdown and the outside-click .rinch-dropdown-menu__backdrop are display: none until it lands. Before issue #760 the class matched nothing and each box was revealed by an inline style rewrite. One consequence of the move: “the dropdown” is now named by its class rather than by its position among the children, so a child other than a DropdownMenuDropdown passed after the target is not hidden with the menu. Use the documented pair.

The DropdownMenuDropdown does not have to be a direct child of the menu. rsx! often wraps it on its own — a {Option<NodeHandle>} child, an else if branch, a component with a reactive prop inside an if, a helper component whose body is an if — and a div of your own around it is fine too; the menu opens through any of them. Nesting is handled as well: a closed DropdownMenu inside an open one’s panel stays closed (its panel and its backdrop, which would otherwise take the clicks meant for the outer menu’s items), and an open one inside an open one’s panel — a submenu — shows. The panel’s open rule is a descendant rule that excludes any panel with a closed menu root between it and an open ancestor:

.rinch-dropdown-menu--opened .rinch-dropdown-menu__dropdown:not(
  .rinch-dropdown-menu--opened .rinch-dropdown-menu:not(.rinch-dropdown-menu--opened)
  .rinch-dropdown-menu__dropdown)

Two consequences of that spelling:

  • A nesting limit: an open menu C inside the target (trigger) of a closed menu B that is itself inside an open menu A keeps its panel hidden — B is closed and sits between A and C’s panel, though C’s own root is open. “Inside A” means either half of A, its panel or its target, and the same holds however many closed menus are stacked in triggers between them. (An open menu in a closed menu’s panel is hidden anyway, correctly.) This is a menu in the trigger of a menu in a menu; it opened before #760, and the limit is pinned by a test so it cannot change silently.
  • Do not put a display on a DropdownMenuDropdown. The panel is hidden by a single-class rule, (0,1,0), and shown by the open rule, (0,6,0). A display of your own that beats the hidden rule — a two-class rule such as .my-menu .rinch-dropdown-menu__dropdown { display: flex }, or an inline style: "display: flex" — keeps the panel visible while the menu is closed, so the menu never hides (measured for the two-class rule on desktop and in Chrome, and for the inline style: on desktop). While the menu is open the same declaration loses to the open rule unless it is more specific still. Put the layout (display: flex, a gap) on a child of the DropdownMenuDropdown instead, as with TabsPanel. A style: on the panel that sets anything other than display is kept.
PropTypeDefaultDescription
openedboolfalse
opened_fnOption<ReactiveBool>NoneReactive opened getter — use this for fine-grained updates
on_closeOption<Callback>NoneFired when the user clicks outside the menu or clicks a menu item (with close_on_click_outside/close_on_item_click); the caller should set their opened_fn source to false from here
positionString""
offsetOption<i32>None
radiusString""
shadowString""
close_on_click_outsidebooltrue
close_on_item_clickbooltrue
widthString""
z_indexOption<i32>NoneStacking level of the menu panel; its click-catching backdrop stays one below (defaults 100 / 99)

DropdownMenuTarget, DropdownMenuDropdown, DropdownMenuLabel, DropdownMenuDivider: No props.

DropdownMenuItem:

PropTypeDefaultDescription
left_sectionOption<TablerIcon>None
right_sectionOption<TablerIcon>None
colorString""
disabledboolfalse
onclickOption<Callback>None

HoverCard

PropTypeDefaultDescription
positionString""
offsetOption<i32>None
radiusString""
shadowString""
widthString""
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_blurString""
loader_typeString""
loader_sizeString""
loader_colorString""
radiusString""
z_indexOption<i32>None
transition_durationOption<u32>None

Tabs

PropTypeDefaultDescription
valueString""Active tab value
default_valueString""
variantString""“default”, “outline”, “pills”
orientationString""“horizontal”, “vertical”
positionString""
growboolfalse
colorString""
radiusString""One of xs sm md lg xl, rounding the tab buttons. outline tabs keep their bottom corners square; the default underline variant has no visible corners to round at rest, though its hover background does

TabsList: grow: bool, justify: String.

Tab:

PropTypeDefaultDescription
valueString""Tab identifier
disabledboolfalse
left_sectionOption<TablerIcon>None
right_sectionOption<TablerIcon>None
onclickOption<Callback>None

TabsPanel: value: String — matches the Tab value.

The active tab and the hidden panel are both stylable. The active tab carries data-active="true" and the class rinch-tabs__tab--active (inactive tabs carry data-active="false"), and the inactive panel carries the HTML hidden attribute, which .rinch-tabs__panel[hidden] turns into display: none. All three are what the shipped sheet styles against, and you can style against them too, with two things to know:

  • Do not put a display on a TabsPanel. hidden hides a panel only through that one rule — desktop has no UA [hidden] rule, and a browser’s loses to any author display — so a display of your own on the panel that wins against .rinch-tabs__panel[hidden] (an inline style: "display: flex" always does) keeps the inactive panel showing, on desktop and in the browser alike. Put the layout on a child of the panel instead.
  • Spell a variant rule from the root with child combinators — .rinch-tabs--pills > .rinch-tabs__list > .rinch-tabs__tab--active, as the shipped sheet does for the active pills and outline styling and the vertical indicator — if your Tabs can nest. A Tabs inside another’s panel is inside that root too, so a descendant rule reaches the nested tabs.

The default variant’s underline is a real element, .rinch-tabs__tab-indicator, appended to each tab button; it carries transition: background-color 150ms ease, so switching tabs animates it. The tab button’s own transition: color starts on a switch as well, but what it animates differs by backend: in a browser the label text inherits the animated colour and fades, while on desktop a transition on an inherited property does not reach descendants (visibility is the one exception, since #759), so the label text — which lives in a child span — snaps to its new colour.

None of these hooks was live before issue #760: the component set neither hook and wrote the active colour, the pills fill, the outline border and the underline inline, so all four of the sheet’s [data-active]/--active rules (eight selectors) were unreachable and the underline’s declared transition never ran. One visible consequence of handing the indicator to the sheet: a vertical default Tabs draws its indicator as a 2px bar down the tab’s right edge, which is what the sheet always declared, where before #760 it drew the horizontal underline along the tab’s bottom edge.

Accordion

PropTypeDefaultDescription
valueString""Active item value
default_valueString""
variantString""“default”, “contained”, “filled”, “separated”
radiusString""
multipleboolfalseAllow multiple open items
chevron_positionString""“left”, “right”
disable_chevron_rotationboolfalseHolds the chevron still while an item is open (#707), through data-disable-chevron-rotation on the root. The rotation is a class rather than an inline style so this can outrank it

AccordionItem: value: String.

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

AccordionPanel: No props.

PropTypeDefaultDescription
separatorString""Custom separator character
separator_marginString""Spacing around separator

BreadcrumbsItem: href: 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
sizeString""
radiusString""
with_edgesboolfalseShow first/last page buttons
with_controlsbooltrueShow prev/next buttons
colorString""
disabledboolfalse
gapString""
onchangeOption<ValueCallback<u32>>NoneReceives page number
PropTypeDefaultDescription
labelString""
descriptionString""
hrefString""
activeboolfalse
active_fnOption<ReactiveBool>NoneReactive active binding (auto-wrapped)
variantString""“light”, “filled”, “subtle”
colorString""
left_sectionOption<TablerIcon>None
right_sectionOption<TablerIcon>None
disabledboolfalse
children_offsetString""Indentation for nested NavLinks
openedboolfalseNested section expanded
default_openedboolfalse
no_wrapboolfalse
onclickOption<Callback>None

Stepper

PropTypeDefaultDescription
activeu320Active step index. Every step takes its state from its position against this — before it completed, at it in progress, after it inactive (#709) — unless it named a state of its own. A step that arrives later is stated as it lands, and so are the steps it displaced (#716); a step removed, or moved out to another stepper, renumbers and restates the ones behind it too (#745). As a closure (active: {|| sig.get()}) it re-renders the stepper and re-derives
sizeString""
orientationString""“horizontal”, “vertical”
colorString""
radiusString""
icon_sizeString""
allow_next_steps_selectboolfalseMakes each step past active clickable (#707), a step that arrives later included (#716). Grants only: a step’s own allow_step_click / allow_step_select is never taken away. With no on_step_click set, this grants only the class — exactly as before #737
on_step_clickOption<ValueCallback<u32>>NoneFired with a step’s 0-based position when it is clicked or activated (Enter/Space) on a step the stepper considers reachable (#737). Mantine’s default: a strictly completed step (position < active) is clickable without allow_next_steps_select; the active step itself and every step past it need that flag or its own allow_step_click/allow_step_select (Mantine’s active state is 'stepProgress', not 'stepCompleted', so it is not reachable by default either). Stepper does not move active itself — the caller does, from this callback, as Tabs moves its own selection. The index is read from the step’s current position at click time, not baked in when the handler was wired (#714’s pattern), since a later insertion or removal can renumber a step (#716, #745) — and the clickable class is removed the moment a shift makes a step unreachable, unless the step’s own ask keeps it. A step moved into another Stepper calls that stepper’s callback, with its position there (#1428); moved into one with no on_step_click it is no longer wired (no data-rid, tabindex or role), and keeps the clickable class only if it asked for it itself or that stepper’s allow_next_steps_select grants it. A wired step moved out of every stepper keeps its wiring (#1453)
completed_iconOption<TablerIcon>NoneDefault completed icon for the steps that set none (#707); the step’s own wins, and a step that arrives later takes this one as it lands (#716)
progress_iconOption<TablerIcon>NoneDefault in-progress icon for the steps that set none (#707). It stands in for the progress_icon the step did not set, so it outranks that step’s plain icon. A step that arrives later takes it as it lands (#716)

StepperStep:

PropTypeDefaultDescription
labelString""
descriptionString""
iconOption<TablerIcon>NoneDefault icon
completed_iconOption<TablerIcon>NonePer-step override
progress_iconOption<TablerIcon>NonePer-step override
allow_step_clickboolfalseGrants this step the clickable class regardless of its position against active. With Stepper::on_step_click set, also grants the wiring (handler, tabindex="0", data-rid) unless disabled is set (#737)
allow_step_selectboolfalseThe same grant as allow_step_click — Mantine keeps both names, so rinch does too
loadingboolfalse
disabledboolfalseNever clickable, never focusable, whatever else grants it (#737) — the plain HTML disabled attribute, read tag-agnostically
stateString"""completed" or "progress"; anything else is inactive. Leave it unset and the parent Stepper derives it from this step’s position against active (#709). Set it to override that for this one step: what a step names, it keeps
stepOption<u32>NoneStep index, 0-based. Set by the parent Stepper from this step’s position unless named here, in which case that is the index shown — and the number in the icon box, one higher. An index named here changes the label, not the position the state derives from

StepperCompleted: No props.

StepperCompleted is not a position (issue #741): it is what the stepper shows instead of its steps once they are all done. The parent’s step walk skips it, so a StepperStep placed inside it keeps the index and state it rendered itself with, and a whole Stepper nested inside it keeps its own.

It is a skip and not a full stop, so the count runs straight across the block and a step placed after it is numbered like any other. Mantine writes Stepper.Completed last by convention but does not require it: a stepper whose completed block comes first still derives all of its steps.

Tree

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

PropTypeDefaultDescription
dataVec<TreeNodeData>[]Tree data
treeOption<UseTreeReturn>NoneState from UseTreeReturn::new()
level_offsetString"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
data_sourceOption<Rc<dyn Fn() -> Vec<TreeNodeData>>>NoneReactive data source — when provided, root nodes update when this changes

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


Window

BorderlessWindow

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

PropTypeDefaultDescription
titleString""Window title in titlebar
radiusString""Corner 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

FloatingPanel

A draggable, resizable floating panel for desktop app patterns — tool panels, property inspectors, floating toolbars. Position and size are controlled via Signal<f32> props (each defaults to an internal signal when not given — x/y default to 50.0, width to 300.0, height to 200.0); the panel writes back to them during drag/resize.

PropTypeDefaultDescription
titleString""Displayed in the panel header; header renders no title span when empty
xOption<Signal<f32>>NoneX position (viewport px)
yOption<Signal<f32>>NoneY position (viewport px)
widthOption<Signal<f32>>NoneWidth (viewport px)
heightOption<Signal<f32>>NoneHeight (viewport px)
min_widthOption<f32>NoneMinimum width constraint (falls back to 200.0)
min_heightOption<f32>NoneMinimum height constraint (falls back to 100.0)
resizablebooltrueWhether the panel can be resized
on_closeOption<Callback>NoneShows a close button in the header when set
#![allow(unused)]
fn main() {
let x = Signal::new(100.0);
let y = Signal::new(100.0);
let w = Signal::new(300.0);
let h = Signal::new(200.0);

rsx! {
    FloatingPanel {
        title: "Properties",
        x: x,
        y: y,
        width: w,
        height: h,
        on_close: move || { /* hide panel */ },
        div { "Panel content here" }
    }
}
}

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()
    };
    App::new(app)
        .title("My App")
        .size(800, 600)
        .theme(theme)
        .run();
}

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.

Omit .theme(...) to get the default theme (blue primary, light mode, small radius) — it is loaded either way, which is what makes components visible out of the box.

ThemeProviderProps

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

CSS Variables

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

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

Colors

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

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

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

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

Semantic Colors

These flip automatically in dark mode:

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

Spacing

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

Border Radius

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

Typography

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

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

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

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

These drive the Title component. A bare <h1>–<h6> does not read them: it gets the browser’s own user-agent typography instead — 2em down to 0.67em, font-weight: bold, and em block margins, exactly the values the HTML Standard’s rendering section gives (issue #627). <th> likewise gets font-weight: bold and a centring. Those are UA-origin cascade rules, so any declaration of your own beats them, and Title (which declares its own size, weight and margin: 0) is unaffected. Reach for Title when you want the theme’s scale and a plain heading tag when you want the browser’s.

The <th> centring is conditional, as it is in a browser: the spec’s rule matches only a th whose parent’s computed text-align is still the initial value, so a header cell inside a container you have aligned inherits that alignment rather than being re-centred. If you want a header cell centred regardless, say so on the cell — an author declaration always wins.

The rest of the browser’s defaults

The same audit (issue #674) finished the job for the elements around a heading, every value measured in Chrome 150 and every one a UA-origin cascade rule your own declaration beats:

elementwhat it now gets
p, blockquote, figure, ul, ol, menu, dir, premargin-block: 1em
blockquote, figuremargin-inline: 40px
ddmargin-inline-start: 40px
menu, dirthe whole ul treatment — display: block and padding-left: 40px as well
one of ul/ol/menu/dir inside anothermargin-block: 0 — no compounding gap per level
prewhite-space: pre, so it preserves runs of spaces and newlines
code, kbd, samp, prefont-family: monospace
small, sub, supfont-size: smaller
hrcolor: gray, a 1px inset border, margin-block: 0.5em, margin-inline: auto, height: 0, overflow: hidden

Before this an <hr> rendered nothing at all — the UA sheet’s own * { border-width: 0 } reset, which is there to undo Stylo’s medium initial, applied to <hr> too and nothing put the border back.

Four things about that set are worth knowing before they surprise you.

  • The <hr> rule is coloured through color, not border-color. The border is currentcolor, so <hr style="color: #c00"> gives you a red rule and there is no need to restate the border. border: none removes it outright, which is what the Divider component does.
  • A bare <hr> in a Stack collapses to a dot. margin-inline: auto are auto cross-axis margins on a flex item, and those suppress the stretch that would otherwise give it the container’s width — so it shrinks to its content width, which for an <hr> is nothing, and centres. A browser does exactly the same. Use Divider, or give the rule a width: 100%.
  • Whitespace in <pre> is preserved now, which means a <pre> whose source was indented in your rsx! will show that indentation. That is the element doing its job; white-space: normal on it opts out.
  • rinch does not copy Chrome’s monospace font-size quirk. Chrome renders a medium-sized monospace element at 13px rather than 16px; rinch has no per-family default size, so <code> keeps the size it inherits. Only the family comes from the UA sheet.

Text controls

A raw <input> or <textarea> carries the browser’s own box and font (issue #1194, every value measured in Chrome 153). The input components (TextInput, Textarea, NumberInput, PasswordInput, ColorInput) declare their own border, padding and font size, so this is about controls you write by hand:

controlboxfont and text
text-state <input> (no type, text, search, email, password, number, …)padding: 1px 2px, border: 2px inset grey13.3333px Arial, weight and style normal, line-height: normal
date/time <input>padding: 0 0 0 1px (inline start only), the same borderthe same, in monospace
checkbox, radio, range, file, image, hiddennonethe same 13.3333px font
the button types, colornone in rinch — Chrome gives submit/reset/button 1px 6px and a 2px outset border, color 1px 2px and a 1px solid one (not modelled yet)the same 13.3333px font
<textarea>padding: 2px, border: 1px solid grey13.3333px monospace, white-space: pre-wrap, overflow-wrap: break-word

Both controls also reset letter-spacing, word-spacing, text-transform, text-indent and text-align rather than inheriting them. The font is not cosmetic: a control is size (or cols) average characters of its own font wide.

Overflow follows Chrome too. An <input> other than a checkbox, radio or range is overflow: clip, and stays clip whatever you declare (the UA rule is !important). A <textarea>’s computed overflow is auto, and a visible you declare on either axis computes auto (a hidden or clip is kept), so a raw textarea shrinks as a flex item instead of holding its full width. rinch does not yet lay out or scroll a textarea’s text, so it shows no scrollbar and takes no wheel even when its value overflows.

Everything else is an ordinary UA rule your own declaration beats. The theme’s button, input, select, textarea { font-family: inherit } is one such declaration, so under the theme a raw control takes the page’s family and keeps the 13.3333px size — as it does in a browser under the same sheet. The colours (FieldText on Field) are not copied: the theme gives form controls color: inherit on purpose, so they stay readable in dark mode.

vertical-align: sub/super on <sub>/<sup> is now part of this (issue #724): ComputedStyle::vertical_align plus its ifc::vertical_align_shift_px consumer shift the glyph post-layout, calibrated against Chrome 153 and the bundled Inter at a 16px parent font-size (exact only there — see that doc for the numbers). top/text-top/middle/bottom/text-bottom parse but still lay out as baseline, and the shift does not grow the line box the way Chrome’s does, both filed as #1357.

List markers on <li> (issue #725) are partly part of this. li computes display: list-item, and the UA sheet gives lists Chrome’s own defaults: ul, menu and dir are disc, ol is decimal, a list inside one other list is circle and inside two is square — an ol counts as a level, so ol > li > ul is circle, measured in Chrome 153. A block-level list-item box lays out as a block one does (its children stack, its height is theirs); display: inline list-item stays inline.

What desktop draws is a text marker followed by an en space: •, ◦ or ▪ for disc, circle or square, N. for decimal (<ol start> and <li value> included), and the old bullet or number for any other keyword. It is generated as a span at the start of an <li> whose parent is a <ul> or <ol>, whose computed display is list-item, whose list-style-type is not none and which has no ::before or ::after of its own (Chrome draws the marker beside either; a menu/dir item gets none yet — #1370). So an item restyled to display: flex — a List item with an icon — draws no marker. The marker is inline content of the item, not an outside marker hanging in its padding, and list-style-position is not read (#1356). The rich-text editor’s stylesheet takes that span out of flow and hangs it against the item’s left edge, as a browser’s outside marker hangs, and draws its task-list checkbox itself as a ::before hung the same way (#1246).

Components are unaffected, and that is pinned rather than assumed — Divider, List, Breadcrumbs, Tree, Image, Blockquote and Code all declare margin: 0, so the author cascade beats every new rule and nothing you render through the component library moves.

The rich-text editor has one exception: its <sub> and <sup> shrink. Its stylesheet declares its own margin for every block tag above, its own font-family for code/pre and white-space: pre-wrap on pre, so all of that is unmoved — but it declares no font-size for sub/sup, which it renders as literal elements. So editor subscripts and superscripts now take the browser’s smaller, exactly as they always have in a browser and on rinch-web. Declare a font-size on them if you want the old desktop size back.

Letter and word spacing

letter-spacing and word-spacing apply to laid-out and painted text on desktop (issue #698). Both inherit, both take a length, and both follow css-text-3: the extra advance is added after every typographic character unit, the last one included, so letter-spacing: 4px over five characters widens the line by 20px rather than 16. word-spacing adds its length to every space and no-break space. Measured against Chrome 150, rinch and the browser agree on all three numbers.

A declaration on an inline element covers that element’s own characters only:

#![allow(unused)]
fn main() {
rsx! {
    p { style: "letter-spacing: 0.08em",
        "Spaced out, "
        span { style: "letter-spacing: normal", "but not here." }
    }
}
}

A percentage resolves against the element’s own font-size (issue #743), on both properties: letter-spacing: 50% / word-spacing: 50% add 10px per affected character at font-size: 20px, and calc(5px + 50%) adds 15px — measured in Chrome 153 and matched here, including word-spacing, whose percentage css-text-4 describes as relative to the space glyph’s own advance rather than the font-size; Chrome’s actual behaviour (and rinch’s, now) is the font-size basis, not that reading.

normal is zero, as in CSS, and it is a genuine reset: an inline element declaring it inside a spaced ancestor gets no spacing on its own characters, which is what the span above relies on.

Form controls are the exception. An <input>, <textarea> or <select> shapes its own value through a separate text path that does not read either property yet, so a spacing declared on one has no effect on the text inside it. Tracked as issue #320, which unifies that path with the rest.

Scaling the whole UI with rem

rem lengths resolve against the computed font-size of the root (<html>) element, exactly as in a browser. The root defaults to 16px, so 1rem is 16px until you say otherwise; html { font-size: 20px } (or a relative value like 125%, resolved against the 16px initial) rescales every rem length in the app — stylesheets and inline styles alike — which makes a root font-size rule the one-line way to scale a rem-based design up or down. Changing it at runtime recascades the document, and the basis survives window resizes. em is unaffected: it keeps resolving against the element’s own inherited font-size.

Resolution media queries follow the display

The shell feeds the window’s scale factor to Stylo as device_pixel_ratio, so @media (min-resolution: 2dppx) (and the -webkit-device-pixel-ratio forms), image-set() candidate selection, and border-width device-pixel snapping all reflect the display the window is actually on — from the first paint, and re-resolved when the window moves to a display with a different scale. Fractional scales (1.25, 1.5) match fractionally, as in a browser. This never changes layout geometry: 1 CSS px stays 1 layout unit, and paint applies the scale. One visible consequence: border: 0.5px on a 2x display renders as a real hairline instead of being snapped up to 1px.

Generic font families on Android

monospace, ui-monospace, ui-sans-serif and ui-serif resolve to a real device face on Android. They do not out of the box: the font backend looks the monospace slot up under the literal name "monospace", which /system/fonts is not indexed under, so the slot ends up empty and every <code>/<pre> — and the whole default font_family_monospace stack, whose every name is absent on Android — renders proportional. rinch repairs the map when it builds a font context (rinch_dom::fonts::new_font_context), appending the first candidate the device actually has: Roboto Mono, Droid Sans Mono, Noto Sans Mono, Cutive Mono for the monospace slots. Only slots the platform left empty are touched, and no other platform is affected. ui-rounded and fangsong are left empty — Android ships no face for either, on any platform they fall through to the next entry in the stack.

Shadows

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

Window Chrome Inset

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

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

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

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

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

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

Drawer, Modal, and the top-anchored Notification positions already do this. DropdownMenu’s and Select’s click-catching backdrops are fixed and deliberately do not — a dismiss region has to cover the chrome, or clicking the title bar would leave the menu open.

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

CSS Selector Support (desktop)

Desktop styling runs through Stylo, the same cascade engine Servo uses, so specificity, inheritance, custom properties and @media behave as you expect. The selector surface is narrower than a browser’s, and the gaps are silent: an unsupported selector parses fine and then matches nothing, with no warning. On rinch-web the browser does its own matching, so everything below works there — which is exactly what makes a gap easy to miss.

Measured on the desktop engine:

SelectorDesktopNote
tag, .class, #id, *works#id was silently dropped before #675
an UPPERCASE HTML tag name, in the markup (<DIV>, <Button>)worksas of #739 — a tag is stored ASCII-lowercased in HTML content, matching Stylo’s own lowercased type-selector comparison for an HTML element. A tag that is one of SVG’s own camelCase spellings, however it was cased, is restored to that spelling rather than lowercased (attr_name::fold_tag_name), mirroring #688’s attribute-name fold one row down
descendant / > / + / ~works
[attr], [attr=v], ~=, |=, ^=, $=, *=works
an UPPERCASE attribute name, in the selector or the markup ([DATA-X], <div ID="up">)worksas of #688 — attribute names are stored ASCII-lowercased in HTML content, and Stylo lowercases the selector’s name itself. viewBox and the other camelCase SVG attributes are kept verbatim
[attr=v i] / [attr=v s] (case flags)workssince the style-invalidation work: attribute matching goes through the selectors crate’s own evaluator, the same one Stylo’s invalidation snapshots use. This is the value; the name above is a different question
:hover, :active, :focus, :focus-visibleworks
:enabled, :disabled, :checkedworksform controls only, by design
:link, :any-linkworks<a>/<area> with an href
:visitednever matchesrinch tracks no history
:first-child, :nth-child(), :not(), :is(), :where(), :root, :emptyworkshandled by the selector engine itself
:has()never matchesstylo’s servo-feature SelectorParser answers parse_has() -> false (#680), so div:has(em) { … } never parses into a relative selector in the first place — an upstream-dependency limitation, not a rinch-dom matching bug, and fixing it needs a patched/forked stylo (the same shape as the vendored wgpu fork)
:required, :optional, :read-only, :read-write, :placeholder-shown, :defined, :lang()works (#681)narrower than the full CSS definition — see Node::tag_supports_required / Node::tag_is_readonly_capable’s docs for the exact scope each leaves out (no <fieldset disabled> inheritance, no non-form-control :read-only; :lang()’s invalidation is a forced self+subtree+later-siblings restyle on a lang write, not Stylo’s state maps)
:focus-withinnever matchesattempted for #681 and reverted: an eager ancestor-propagated bit went stale under style_invalidation_twin_tests.rs’s random-mutation oracle (.a:focus-within) whenever a subtree was Moved while focus lived elsewhere — see stylo_impl.rs’s comment at the pseudo-class match’s catch-all
:indeterminate, :valid, :invalid, :in-range, :out-of-range, :default, :target, :fullscreen, :modal, :popover-open, :autofill, :user-valid, :user-invalidnever matchthey parse, then fall through to a catch-all false — rinch has no constraint validation, no document/fragment concept, and no “default” tracking distinct from current state
::before, ::afterworks
camelCase SVG type selectors (linearGradient, clipPath)works (#683)is_html_element_in_html_document answers false for SVG content tags (attr_name::is_svg_content_tag), so the selector keeps the author’s exact spelling instead of being lowercased as if every element were HTML
width / height attributes on <img>, <video>, <iframe>worksmapped to width / height below every author rule and above the UA sheet’s normal rules, plus the pair’s aspect ratio on an image or video (#684)
Other presentational attributes (<td bgcolor>, <img hspace>, <hr width>, <table width>)no effectno hint is synthesized for them (#1419)

Reach for a class where the table says a selector does not match — that is the one spelling with no gap on either backend.

Text decoration (desktop)

text-decoration-line, -color and -style all reach the desktop painter. text-decoration-style: wavy draws a zigzag under the text — the spellcheck squiggle — in the decoration colour, or the text colour when none is given. The other styles (double, dotted, dashed) parse and paint as a solid line. A decoration propagates to descendants as in CSS: a wavy word inside an underlined link keeps the link’s line and gains the wave. The wave follows the text on screen, right-to-left and mixed-direction runs included.

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.

Scrollbars

A desktop scroll container paints an overlay thumb on each axis that is scrollable and overflowing. Pressing the thumb grabs it where it is, and dragging moves it with the pointer; pressing the empty track beside it jumps there, a position along the track mapping linearly onto the scroll range. Two custom properties style it, and both inherit — one declaration on a root node restyles every scroll region in the app.

:root {
  --rinch-scrollbar-color: rgba(255, 255, 255, 0.35);              /* thumb */
  --rinch-scrollbar-color: rgb(120 120 130) rgba(0, 0, 0, 0.15);   /* thumb, track */
  --rinch-scrollbar-width: auto;                                   /* auto | thin | none */
}
  • --rinch-scrollbar-color: <thumb> [<track>] — any CSS colour. With one colour only the thumb is painted, which is rinch’s default overlay look; with two, a track is painted behind it for the full length of the bar. auto (the initial value) keeps the built-in default described below.
  • --rinch-scrollbar-width: auto | thin | none — auto is a 6px thumb, thin a 4px one. none removes the bar entirely: nothing is painted and nothing is hit-tested either, so an app drawing its own scrollbar can switch rinch’s off rather than covering it up.

What counts as overflowing

For the cases in this paragraph, rinch counts the same content a browser does. A position: fixed child is anchored to the viewport, so it never makes an ancestor scroll — a Modal, Drawer or Notification inside an overflow: auto region adds nothing to that region’s scroll range, open or closed. A position: absolute child counts only where its containing block is the container or something inside it — the container is itself positioned (or transformed), or an inline element between them is, such as a position: relative <span> holding the absolute; an absolute that resolves against something further up escapes that container, exactly as it does on the web. visibility: hidden content still counts, because it still occupies its box; display: none content does not, because it has none. The children of a display: contents element count as the container’s own, since the wrapper has no box — so a region whose rows come from a for, an if branch or an embedded Vec<NodeHandle>, each of which rsx! may put in such a wrapper, scrolls as it would on the web, and so does one holding text — a reactive {|| text} included — whether it is the container’s only content or sits beside block children, as a log panel’s footer line sits after its rows.

Where desktop still differs from a browser:

  • An absolute child of a padded scroll container can add up to the container’s right and bottom padding to its scroll range, which a browser does not. A position: absolute; inset: 0 overlay — a LoadingOverlay, say — inside a padding: 20px; overflow: auto; position: relative panel gives the panel 20px of scroll travel on each axis and two scrollbars, where a browser shows none.
  • A position: relative child with an offset counts only where the offset puts it, while a browser also counts where it would have been. Shifted toward the end of a padded container, it can add up to the container’s right or bottom padding; shifted toward the start, it can take away up to the offset, padded container or not — a 240px-wide child at left: -40px in a 200px-wide overflow: auto box gets no scrollbar, where a browser gives 40px of travel. A relative child with no offset is measured like any other.
  • An absolute whose containing block is further up than its parent is laid out in that containing block (#386) but counted by no scroll container at all; a browser counts it in that ancestor (#770). The exception is a parent that is a non-positioned inline element such as a plain <span> (at any depth of them) sitting directly in the containing block: the box then counts there, as a browser counts it. Under a position: relative span — which is then the box’s containing block — it counts when the scroll container is the block holding the span’s line, placed against the span’s text (#1049, #631); with another block between them it is counted nowhere (#770).
  • A child’s transform and its end margins do not extend the scroll range; a browser’s scroll range includes both.

Why not scrollbar-color / scrollbar-width?

Those are the real CSS properties, and they are what these mirror — but they are gecko-only in Stylo. That is a codegen-time filter rather than a #[cfg], so the servo build rinch uses emits no parser entry for either and drops the declaration. The --rinch- custom properties go through Stylo’s custom-property cascade instead, which the servo build supports fully. If Stylo ever ships the real properties for servo, they will be honoured alongside these.

Neither affects layout: rinch’s bar is an overlay drawn on top of content and takes no space, so thin and none change what you see, not where anything sits.

The default follows your palette

auto is not a fixed colour. The thumb is 40% black or 40% white, chosen by the luminance of the container’s own computed color. A light theme’s text is dark, so the thumb is the 40% black it has always been and nothing changes; a dark theme’s text is light, so the thumb flips to white and is visible.

Only the polarity follows the palette — the thumb stays neutral grey — so a container that happens to set color: red does not get a red thumb. color is read rather than background-color because it is always resolved: backgrounds are transparent by default, so a scroll region inheriting the page’s dark background would have nothing to read.

Desktop only. On the web the browser draws its own scrollbars, and the real CSS properties work there.

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.

CSS sizing keywords

For width, height, min-width/min-height, max-width/max-height and flex-basis, rinch supports lengths, percentages, calc() mixing the two, auto and none. The intrinsic sizing keywords are supported on width, height and flex-basis (#691), and parse but lay out as auto on min-*/max-* (#1275).

Valuewidth / height / flex-basismin-* / max-*
<length>, <percentage>, calc() of the twoSupportedSupported
auto, noneSupportedSupported
max-contentSupportedLaid out as auto
min-contentSupportedLaid out as auto
fit-contentSupportedLaid out as auto
stretch, -webkit-fill-availableSupportedLaid out as auto
fit-content(<length-percentage>)auto — Chrome 153 does not accept it on these properties eitherLaid out as auto
anchor-size(…)Laid out as autoLaid out as auto

They are checked against Chrome 153 in block, flex (both axes, and flex-basis), grid, inline-block/inline-flex, absolute and fixed contexts (crates/rinch-dom/tests/intrinsic_sizing_twin_tests.rs). What still differs, each with its issue:

  • fit-content around content that wraps comes out as wide as its widest wrapped line rather than the available width (#1276) — the same measurement that makes an auto-width absolute too narrow. It is right whenever the content fits on one line, or cannot wrap at all.
  • height: stretch on an inline-block lays out as auto, and width: stretch on a flex item whose content is wider than the container is not capped by it (#1277).
  • An inline-block (or inline-flex, inline-grid) whose width is auto is shrink-to-fit since #658, as in a browser: capped at its containing block, so wider content wraps there. That holds where the block’s own width comes from the box too (#1476): inside a flex item, a grid item in an auto track, a fit-content or min-content block, a shrink-to-fit absolute box or another auto atomic inline, the container is sized from the box’s min-content and max-content widths (its longest word, its one-line width), and the box wraps in what that gives. Two things in this corner are not a browser’s yet. A box whose own min-width or max-width is a percentage, or whose width is fit-content or a percentage, still contributes the size it currently has to such a container, as before, so it is not a browser’s there: a width: fit-content box keeps its full width, and a max-width: 80% box comes out narrower than a browser’s and narrower again on each relayout (older than #1476). A box whose width is a percentage, in a flex item or an auto grid track, is the same cycle: its container is sized from the box and the box from its container, so the first layout is not where it settles — it drifts over the next few layouts, and since #1476 an auto box in a neighbouring item or track moves with it (up to seven layouts, measured). A box that fits within a pixel of its containing block is left unwrapped (a 380.9px line in a 380px block overflows by that pixel).

For min-*/max-* rinch prints one line on stderr per property and keyword per process, so the substitution is visible rather than silent:

[rinch] `min-width: max-content` is not implemented; it lays out as `auto`,
which matches a browser only where `auto` already gives the same used size
(issues #626, #1275). Reported once per property and value per process.

Grid tracks take the keywords too: grid-template-columns: max-content and min-content size a column the way a browser does, and fit-content(<length-percentage>) is converted for tracks.

When auto is the right answer for min-*/max-*

On min-*/max-*, where every keyword is still laid out as auto, whether that changes anything depends on the box. Measured against Chrome 150, for a box whose content is 300px wide inside an 800px containing block:

The boxauto givesmax-content/min-content/fit-contentstretch
min-width / max-widthno constraintwrong — should constrain to 300correct
min-heightnonecorrectwrong — should fill the containing block
max-heightnonecorrectcorrect

A browser shrink-wraps a float too. rinch does not, because rinch implements no CSS float at all — measured, float: left on the same box gives 800, the full containing block, where a browser gives 300. So a float is not a way to shrink-wrap a box here; width: fit-content (or display: inline-block) is.

Where a min-*/max-* keyword is wrong, a declared length or percentage is the workaround: min-height: 100% gives min-height: stretch’s used size as long as the box has no margin, border or padding on that axis.

Fonts

An app that wants a particular typeface has two ways to get one: the platform’s font list, or its own bytes.

The platform list is not a promise anyone can keep. A phone has whatever its OEM shipped. A Linux desktop has whatever the user installed. So a build with a designed identity, or one whose content needs a monospaced face — a chord chart, a diff, a table of figures, anything where the columns lining up is the meaning and not decoration — has to carry the file.

Shipping a face

Bundled fonts are one method on the App builder, so they compose with every other startup option:

use rinch::prelude::*;

const FONTS: &[AppFont] = &[
    AppFont::serif(include_bytes!("../assets/fonts/Newsreader.ttf")),
    AppFont::sans_serif(include_bytes!("../assets/fonts/Karla.ttf")),
    AppFont::monospace(include_bytes!("../assets/fonts/DejaVuSansMono.ttf")),
];

fn main() {
    App::new(app)
        .title("My App")
        .size(900, 1200)
        .fonts(FONTS)
        .run();
}

and on Android, the same chain with a different terminal:

#[unsafe(no_mangle)]
fn android_main(android_app: AndroidApp) {
    App::new(app).fonts(FONTS).run_android(android_app);
}

.fonts() is part of the startup configuration rather than something you register beforehand because the faces have to be in place before the first layout pass. A face that arrives after it cannot un-measure the frame that has already been drawn: the first frame comes out in a fallback and then reflows. Configuring them on the builder leaves no ordering to get wrong.

Calling .fonts() more than once registers all of them, in call order — unlike .title() or .menu(), a second call adds rather than replaces.

AppFont::data is &'static [u8], which is what include_bytes! gives you. The file is a TrueType (.ttf), OpenType (.otf) or collection (.ttc).

What a face is reachable as

This is the part that catches people out. Take a stack:

font-family: Newsreader, Georgia, serif;

Every name in it is looked up one of two ways.

In the stackHow it resolvesWhat a bundled face needs
Newsreader, Georgiaby family name — the name inside the font file’s own name tablenothing; registering the file is enough
serif, sans-serif, monospace, system-ui, …by generic — a slot the platform fills, which is not the name of anythingthe face has to be declared for that slot

So AppFont::new(bytes) is reachable, but only by an author who spells its name. If the stack’s last entry is a generic — and essentially every real stack ends in one — that generic is what has to resolve when the names ahead of it miss, and it will not resolve to a bundled face by accident.

The constructors say which slot a face is claiming:

claims
AppFont::newnothing — reachable by family name only
AppFont::serifserif, ui-serif
AppFont::sans_serifsans-serif, system-ui, ui-sans-serif
AppFont::monospacemonospace, ui-monospace

Anything else is a struct literal, over the full set of CSS generics:

AppFont {
    generics: &[GenericFamily::Cursive, GenericFamily::Fantasy],
    ..AppFont::new(bytes)
}

A bundled face goes ahead of the platform’s own entry for a slot — including the face rinch’s Android repair filled an empty slot with (below). An app that ships a face for monospace means that one. What was in the slot stays behind the claim rather than being discarded, so a character the claimed face lacks can still fall through to the platform’s entry.

Claim only what is true. A serif declared sans-serif will be picked for sans-serif, which is precisely the problem.

Android and monospace

Worth knowing if you are shipping to a phone. The text stack’s Android backend (fontique) maps the monospace generic by looking up a font family named monospace, and its scan of /system/fonts indexes files under the family names in their own name tables — where the device’s actual monospaced file (/system/fonts/DroidSansMono.ttf) is Droid Sans Mono. Nothing is called monospace, so upstream the slot is left empty, and font-family: monospace would resolve to no face at all.

rinch repairs this at font-context construction (issue #322): a generic slot the platform left empty is filled with the best platform face the device actually has — Droid Sans Mono on stock Android for monospace — so <code> and <pre> render monospaced out of the box, with no bundling.

What the repair cannot do is pick your face: it answers an empty slot with whatever the OEM shipped, whichever that is. An app whose content depends on a particular monospaced design bundles the file and declares it — AppFont::monospace(...) — and the declared face is picked ahead of both the repair’s choice and the platform’s own map.

That precedence is the reason a claim is a prepend rather than an append. The repair runs at context construction, before any app font can register, so an appended claim would sit behind the repair’s platform face and silently lose — on Android only, which is the platform nobody would test it on.

A stack that names nothing installed

CSS falls back to the browser’s default font when every family in a font-family list is missing. rinch finishes such a stack with the first face of sans-serif — so font-family: Helvetica on a Linux machine without it draws its letters and digits in the default sans face, or in the face your app registered for sans-serif (AppFont::sans_serif) if it did. (Chrome resolves Helvetica, Times and Courier through fontconfig aliases, and an unknown name in its serif default; rinch knows only the families actually installed or registered.) A stack that finds any family, or names a generic that has one, is used exactly as written — end your stacks in a generic to choose the fallback yourself.

Emoji

An emoji-presentation character — 😀, ⬜, a flag, or an emoji character followed by U+FE0F (❤️, ©️) — is drawn from the emoji generic unless a family in your stack covers it first, where each generic in the stack counts as its first face only; the generics’ other faces come after the emoji generic, so a character the emoji face lacks still finds them. U+FE0F after a character that is not an emoji (𝄞, Thai ก) changes nothing. So under font-family: sans-serif (or the theme’s default stack) an emoji is in colour even on a machine whose sans-serif list goes on to text faces that also have it (DejaVu Sans, FreeSans), as in Chrome; a family you name that covers the emoji still draws it, as in Chrome, and an emoji face you name after a generic (…, sans-serif, 'Segoe UI Emoji') wins it. A character that is text by default — ❤ without U+FE0F, ©, the digits — follows the stack as written.

Platforms with no fonts at all

Wasm has no system font source: an unregistered app renders zero glyphs, not a fallback. That case wants a face in the last-resort fallback chain as well as in a generic slot, which is what RinchApp::register_font_data and RinchContextConfig::fonts do, and what AppFont::script_fallback turns on.

Do not turn it on where the platform does have fonts. The text stack resolves a script’s fallback from the app’s own list first and the platform’s second — as an alternative, not a chain — so an entry in the app’s list replaces that script’s platform fallback rather than extending it. One bundled Latin face becomes what CJK, Arabic and emoji fall back to.

The effect is measured, not hypothetical, and the tests pin it: with script_fallback set on a Latin face, laying out 漢字 in a stack headed by that face resolves to the bundled face, at glyph 0 — .notdef, the empty box — where the same text without it resolves to the platform’s CJK face with real glyphs. You will not always see it: if something else in the author’s stack already covers the character, fallback is never consulted. That makes it worse, not better — the breakage shows up only for the scripts and stacks nobody tested.

Where the bytes come from

include_bytes! puts the file in the binary — in the .so, for an Android build — and needs no packaging. On Android the more idiomatic alternative is to put the files in the APK’s assets/ and read them through AndroidApp’s asset manager, which keeps the shared object smaller; it is also more machinery, it is Android-only, and the bytes it produces are owned rather than 'static. The API here takes &'static [u8], so include_bytes! is the supported route today.

Running on WASM

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

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

How It Works

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

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

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

Project Setup

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

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

The Shared Library

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

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

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

The Desktop Entry Point

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

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

The WASM Entry Point

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

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

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

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

fn main() {}

Islands (multiple roots per page)

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

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

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

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

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

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

Mounting with a menu bar

Browsers have no window menu to attach a native menu bar to. Rinch renders one out of DOM nodes instead — the same bar the Linux desktop uses, built from the same Menu / MenuItem values you would hand to App::menu. So the menus are declared once and both targets render them.

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

#[wasm_bindgen(start)]
pub fn start() {
    let file = Menu::new()
        .item(MenuItem::new("New").shortcut("Ctrl+N").on_click(|| new_doc()))
        .separator()
        .submenu("Open Recent", Menu::new()
            .item(MenuItem::new("notes.md").on_click(|| open("notes.md"))))
        .item(MenuItem::new("Save").shortcut("Ctrl+S").enabled(false));

    let view = Menu::new()
        .item(MenuItem::new("Focus Search").shortcut("Ctrl+K").on_click(|| focus_search()));

    rinch_web::mount_with_menu_bar(
        ThemeProviderProps::default(),
        vec![("File", file), ("View", view)],
        my_app::app,
    );
}
}
APIPurpose
mount_with_menu_bar(theme, menus, build)Whole-page app under a menu bar
mount_into_with_menu_bar(&Element, theme, menus, build) -> RootHandleAn island under its own menu bar
mount_selector_with_menu_bar("#id", theme, menus, build) -> Option<RootHandle>Same, by CSS selector

Each (label, menu) pair becomes one top-level menu, in order. Clicking a label opens it; moving the pointer across the bar switches to the next menu without a second click; clicking outside or pressing Escape dismisses it. Separators and submenus render as flyouts.

The bar is laid out inside whatever it is mounted into, above the content, and the wrapper it builds is viewport-tall — height: 100vh, from the component stylesheet. A whole-page app needs nothing for that; an island gets no say in it through the API, so a host shorter than the viewport is overflowed by the bar and giving that host a height changes nothing. Measured in Chrome 153: with no height on html, body or the host, everything lands at the viewport’s 437px; with the host at height: 120px, the wrapper is still 437px. Tracked separately. An author stylesheet can still override it — the component sheet’s rule is a bare class selector, so div.rinch-app-menu-bar-wrapper { height: 100% } wins on specificity (measured: a 120px host then gets a 120px wrapper).

Shortcuts are armed against the page. Every item’s shortcut string is matched on a capture-phase keydown on window, before the app sees the key, and a chord the menus claim is consumed: preventDefault, so the browser does not also act on it, and the event is stopped before it reaches anything else in the app — the same thing the desktop does by returning before a matched chord becomes an event. Ctrl and Cmd are interchangeable in the string, as on the desktop, so "Ctrl+K" is Cmd+K on a Mac. A chord nothing is listening to falls through to the page: an item with a shortcut but no on_click, or a disabled one, arms nothing.

A few chords are the browser’s own and cannot be taken. The ones that open and close windows and tabs — Ctrl+N, Ctrl+T, Ctrl+W and their Shift variants in Chrome and Firefox — are handled by the browser’s chrome ahead of the page, so preventDefault does not reach them and the page may not be sent the keystroke at all. Exactly which chords those are depends on the browser and the platform, and rinch’s Ctrl↔Cmd mapping shifts the question again on a Mac, where "Ctrl+Q" means Cmd+Q and belongs to the OS. Declaring one is still right when the same Menu drives a desktop build — Ctrl+N is what “New” should be there — just do not rely on the chord in the browser, and check any chord you care about in the browsers you support.

Unmounting gives the chords back. They are page-global, so an island mounted into somebody else’s page arms them against the whole document; RootHandle::unmount releases exactly the ones that island armed. Two islands that each declare menus still share one registry — the later one to arm wins, and the earlier one unmounting leaves the winner alone.

Nothing about this needs the desktop feature. rinch::menu’s declaration types and the DOM renderer build with default-features = false; only the muda builders behind them are desktop-gated. See examples/menu-bar-web for a runnable demo.

Right-click menus

oncontextmenu and ContextMenu behave as they do on the desktop: a right-click on an element with a live handler dispatches it, and the browser’s own menu does not open. Everywhere else it does, which is what an island in somebody else’s page must do. A whole-page app that draws its own menus can take the rest of the page as well:

#![allow(unused)]
fn main() {
rinch_web::set_suppress_native_context_menu(true);
}

ContextMenu needs this to look native: its dropdown is portalled out of the element carrying the handler, so without the flag a right-click inside the open menu opens the browser’s on top of it. The flag is page-global and can be flipped at any time.

It leaves text fields alone. With the flag on, a right-click on a <textarea>, on an <input> of type text, search, url, tel, email, password or number (or no type), or on contenteditable content still opens the browser’s menu, readonly and disabled fields included. That menu is the user’s cut, copy and paste, spelling suggestions and autofill, and a page cannot rebuild it. A live handler still wins: to show your own menu in a field, put oncontextmenu on the field or an ancestor of it. Outside contenteditable content, selected text, a checkbox, a <select> and a label next to a field are still suppressed, and so is a contenteditable="false" island. Inside editable content the browser reports a checkbox, a <select> or a label as editable too, so they keep its menu there.

The rich-text Editor keeps the browser’s editing menu too, flag or no flag (issue #814). Its surface is not contenteditable, so a right-click on it is not an editing context by itself — but a right-button press parks the hidden <textarea> the editor takes its input through under the pointer for the instant the browser needs to hit-test the point, so the contextmenu the browser fires targets that textarea, which the carve-out covers (measured in Chrome 153: a real right press dispatches contextmenu at the textarea, not prevented). The keyboard’s menu key and Shift+F10 go to the focused element, that same textarea; the editor parks it at the caret on that key, so the contextmenu the browser places at the focused element’s box lands at the caret rather than at the textarea’s resting corner. Under an app’s data-oncontextmenu the key opens that handler instead, with its click context at the caret, and the browser’s menu is prevented. A contextmenu dispatched at the surface itself with nothing parked — by script, or by a touch long-press (#821) — is still suppressed with the flag on. So is a right-click on a link or an image in the editor outside the selection: the editor parks nothing there, so that the browser’s own link or image menu can open, and a link or an image is not an editing context, so the flag takes that menu away as it does on any other link or image. What the menu’s items do is described in the editor guide.

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

Browsers, and running the browser tests

The browser backend is tested in Chrome and Firefox: CI runs crates/rinch-web’s browser tests (and rinch-storage’s IndexedDB tests) in both, headless, on every pull request (test-wasm in .github/workflows/ci.yml). Safari is not run anywhere.

The tests are ordinary wasm-bindgen-test files under crates/rinch-web/tests/. wasm-bindgen-test-runner loads each in a real browser through that browser’s WebDriver, and which driver it finds decides the browser:

# Once: the runner, at the wasm-bindgen version Cargo.lock pins.
cargo install wasm-bindgen-cli --version "$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | grep -o '[0-9.]*')" --locked
export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner

# Chrome: a chromedriver of the same version as the installed Chrome.
CHROMEDRIVER=/path/to/chromedriver \
  cargo test -p rinch-web --target wasm32-unknown-unknown

# Firefox: geckodriver (https://github.com/mozilla/geckodriver/releases), with
# `firefox` on PATH.
GECKODRIVER=/path/to/geckodriver \
  cargo test -p rinch-web --target wasm32-unknown-unknown

# One file, and the tests behind the `collaboration` feature:
GECKODRIVER=/path/to/geckodriver \
  cargo test -p rinch-web --target wasm32-unknown-unknown --features collaboration --test editor_read_only

When both variables are set, GECKODRIVER wins. To use a Firefox that is not on PATH, put a webdriver.json in crates/rinch-web/ (do not commit it):

{ "moz:firefoxOptions": { "binary": "/path/to/firefox", "args": ["-headless"] } }

Two things to know when a test passes in one engine only:

  • A synthetic ClipboardEvent does not carry its clipboardData in Firefox. new ClipboardEvent("paste", { clipboardData }) hands the listeners an empty transfer there. Build clipboard events with tests/support/clipboard.rs.
  • The engines answer geometry questions differently, which is often why the code under test exists: after a preserved newline Chrome gives a collapsed range no rect and Firefox gives it the line above; a <br> on an empty line is the glyph box in Chrome and the line box in Firefox. A positive control that pins the browser’s own answer states each engine’s (tests/support/engine.rs, is_gecko()), never one engine’s for both.

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.

Browsers. The wasm suites run in Chrome only. A press, drag or hover in the Editor (and the data-block-index text hit) finds its text position with caretRangeFromPoint where the browser has it and the standard caretPositionFromPoint otherwise, which is what Firefox has. That second path is tested by hiding the first in Chrome, where the two give the same positions; it has not been run in Firefox, and neither has the rest of the editor’s input there (#1461).

What Doesn’t (Yet)

  • Custom painting — Vello and tiny-skia don’t run in the browser. The browser paints for you instead.
  • Game engine embedding — RenderSurface and RinchContext are desktop-only.
  • Native menus — There is no OS menu bar to attach one to. Rinch renders its own DOM menu bar instead, from the same Menu/MenuItem declarations the desktop uses — see Mounting with a menu bar.
  • 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, and examples/menu-bar-web for the menu bar.

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, size, and root component
    App::new(app).title("My Application").size(800, 600).run();
}

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

    App::new(app).window_props(props).run();
}
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
visiblebooltrueWhether the window is on screen at startup; false starts a tray-only app (see below)
resize_insetOption<f32>NoneResize handle inset for borderless windows
non_activatingboolfalseNever take keyboard focus or activation, even when clicked (see Overlay Windows)
click_throughboolfalseLet pointer events pass through to the window underneath
skip_taskbarboolfalseNo taskbar entry (and on Windows, none in Alt+Tab)
levelWindowLevelNormalHow high the window sits: Normal, Floating, StatusBar, ScreenSaver (see Level and workspaces)
visible_on_all_workspacesboolfalseShow the window on every workspace / Space, fullscreen ones included

Starting with no window (tray-only apps)

visible: false starts the app with no window on screen (issue #1520): the tray is the whole UI until something calls show_current_window(), for example a “Settings…” tray item. The root component is mounted at startup all the same, at the window’s size, so its effects, timers and stores run while nothing is shown. Showing the window creates the OS window over that same document, without mounting it again. hide_current_window() hides it again, and so does an on_close_requested that calls hide_current_window() and returns false (returning false alone only cancels the exit). Desktop only; no frame is presented before the window is first shown (on X11 the window is created unmapped and destroyed again, measured with xev). No renderer exists before the first show either, so gpu_handle() is None while the root mounts.

#![allow(unused)]
fn main() {
let props = WindowProps {
    title: "Settings".into(),
    visible: false,
    on_close_requested: Some(Arc::new(|| {
        hide_current_window();
        false
    })),
    ..Default::default()
};
App::new(app).window_props(props).run();
}

examples/tray-only is a complete one. Before #1520, visible was accepted and ignored: the window was always shown.

On macOS a hidden window still leaves the app’s icon in the Dock, because the Dock icon belongs to the app, not the window. A tray-only app pairs visible: false with the accessory activation policy (issue #1528), which removes the Dock icon and the app’s menu bar while its window can still be shown and typed into:

#![allow(unused)]
fn main() {
App::new(app)
    .window_props(props)
    .activation_policy(ActivationPolicy::Accessory)
    .run();
}

visible: false does not imply it: an app that only starts hidden and is otherwise an ordinary app keeps its Dock icon. Without activation_policy the policy is winit’s default, unchanged: Regular for a bare executable, and the bundle’s Info.plist (LSUIElement) for a .app; setting one overrides the Info.plist. ActivationPolicy::Prohibited forbids windows altogether. On Windows and Linux the call does nothing — an app has a taskbar entry there only while a window is shown.

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

    App::new(app).window_props(props).run();
}

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. set_click_through_current_window(bool) belongs to the same family; see Overlay Windows.

Hiding, showing, and where the window comes back

hide_current_window() takes the window off screen and show_current_window() puts it back. The window comes back where it was and at the size it had when it was hidden, including a position and size the user gave it by dragging (issue #1546). On Wayland only the size comes back: the compositor places windows, and a client cannot learn or choose its position — except a layer-shell overlay, which is placed by rinch and comes back where it was. A maximized window comes back maximized, and un-maximizing it returns it to the last place and size it had before it was maximized. A minimized window’s position is never recorded (Windows reports it as (-32000, -32000)), and a position that lies on no connected monitor when the window is shown (a monitor unplugged while it was hidden) is dropped, so the system places the window.

The shell does this by recording the window’s outer (frame) position and inner size at the hide and creating the new window with them, rather than by keeping an invisible window: Wayland has no invisible toplevel, and on macOS winit’s set_visible(true) makes the window key, which would activate a non-activating panel. A non-activating window is still shown without taking focus, and click-through and skip-taskbar are kept.

The first time a window is shown after a hidden start, it appears at WindowProps::x/y, as a window created visible does.

Reading and setting the window’s geometry

FunctionDescription
current_window_position() -> Option<(f64, f64)>Outer top-left in logical pixels; None before the window exists and on Wayland (a layer-shell overlay answers its position on its output)
current_window_size() -> Option<(f64, f64)>Inner (content) size in logical pixels; None before the window exists
set_current_window_position(x, y)Move the outer top-left (ignored on Wayland, except a layer-shell overlay)
current_window_surface_kind() -> Option<WindowSurfaceKind>Toplevel, or LayerShell for a Wayland layer-shell overlay
set_current_window_output(Option<&str>)The output a layer-shell overlay is on, by connector name
start_window_drag() / start_window_resize(edge)Move or resize the window with the pointer, from a press handler
set_current_window_size(width, height)Resize the content area

They are in the prelude and callable from any thread. The getters keep answering while the window is hidden, so an app can read them at exit and persist them; the setters, called while it is hidden, decide where and how big it comes back — except for a window hidden while maximized, which comes back maximized, the set geometry becoming the one it returns to when un-maximized. On Wayland the position setter does nothing and the position getter always answers None, except for a layer-shell overlay. A setter takes effect on the event loop’s next turn, so a getter called right after it still answers the old value. The window manager may adjust a requested size or position.

#![allow(unused)]
fn main() {
// At exit (or in on_close_requested): remember the geometry.
if let (Some((x, y)), Some((w, h))) = (current_window_position(), current_window_size()) {
    save_geometry(x, y, w, h);
}

// At startup: pass it back through WindowProps.
let props = WindowProps {
    x: saved.map(|g| g.x as i32),
    y: saved.map(|g| g.y as i32),
    width: saved.map_or(800, |g| g.w as u32),
    height: saved.map_or(600, |g| g.h as u32),
    ..Default::default()
};
}

The position is in logical pixels, like WindowProps::x/y. On a desktop whose monitors have different scale factors a logical position is ambiguous; the hide/show round trip itself does not go through it (it keeps desktop pixels on X11 and Windows and points on macOS), so a window comes back to the pixel.

Fixed Overlays and Custom Chrome

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

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

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

Give a fixed overlay an inset on both axes. An axis with neither inset (left and right both auto, or top and bottom) keeps the box where it stands in the flow on that axis — its static position, as in a browser — so top: 0; bottom: 0 alone starts wherever the element’s parent put it horizontally, not at the window’s left edge. (Before #633 such a box went to the window’s origin on the desktop backend.)

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

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

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

Transparent Windows

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

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

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

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

    App::new(app).window_props(props).run();
}

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 from each edge — the inset is the whole grab zone, with nothing added to it
  • A corner is simply where two edge zones meet, a resize_inset x resize_inset square; it is not enlarged, so raise resize_inset if you want bigger diagonal handles
  • A visible scrollbar thumb takes an edge press (and the hover cursor) off the zone where the two overlap; the empty track past the thumb still resizes, and a corner never yields to the thumb
  • The cursor automatically changes to indicate resize direction when hovering edges
  • Only works when both borderless: true and resizable: true are set (for such windows the shell defaults resize_inset to Some(8.0))
  • 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()
    };
    App::new(app).window_props(props).run();
}

Observing Window Resizes

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

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

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

Overlay Windows

An overlay — a status pill, a HUD, an indicator — sits on top of another application while the user keeps typing into that application. It must not take the keyboard: not when it is created, not when it is shown, and not when it is clicked. Three WindowProps fields ask for that (issue #1519):

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

App::new(pill)
    .window_props(WindowProps {
        width: 360,
        height: 44,
        borderless: true,
        transparent: true,
        always_on_top: true,
        non_activating: true, // never takes focus or activation
        skip_taskbar: true,   // no taskbar / Alt+Tab entry
        click_through: false, // the pill's buttons take clicks
        ..Default::default()
    })
    .prefer_x11(true) // see "Wayland" below
    .run();
}

A non-activating window still takes pointer clicks in its DOM, so it can have buttons; it does not take keystrokes, so a text field in it cannot be typed into. show_current_window() shows it without activating it.

click_through: true lets pointer events pass to whatever is under the window. Change it at run time with set_click_through_current_window(bool), from any thread. A click-through window receives no pointer events at all, so nothing inside it can turn click-through off again: do it from a hotkey, a tray item, a timer or another thread. The setting is kept when the window is hidden and shown again.

Platformnon_activatingclick_throughskip_taskbar
WindowsWS_EX_NOACTIVATE; WM_MOUSEACTIVATE answers MA_NOACTIVATE; first shown with SW_SHOWNOACTIVATEWS_EX_TRANSPARENT | WS_EX_LAYEREDWS_EX_TOOLWINDOW (no taskbar button, no Alt+Tab entry)
macOSa non-activating NSPanel with becomesKeyOnlyIfNeeded, kept on screen while its app is inactive, ordered front at creation without becoming keyignoresMouseEventsa window has no Dock entry of its own (the app’s Dock icon and Cmd-Tab entry are the activation policy’s business); the window is kept out of Cmd- cycling and Mission Control (ignoresCycle | stationary`)
X11WM_HINTS.input = Falsean empty input shape (set by rinch over its own connection, XFixes version negotiated)_NET_WM_STATE_SKIP_TASKBAR and _SKIP_PAGER
Waylanda wlr-layer-shell surface with no keyboard (below); no effect on GNOMEan empty input regiona layer surface has no taskbar entry; alone, no effect

On Windows and X11 such a window is created hidden, adjusted, and only then shown, so it is never on screen in a state that could take focus. On macOS it is shown at creation (winit orders a window created inactive to the front without making it key) and adjusted right after, before any event is handled: showing a hidden window later would make it key. A window without these fields takes exactly the path it always did.

Level and workspaces

Two more fields place an overlay among other windows and workspaces (issue #1549):

#![allow(unused)]
fn main() {
use rinch_core::element::{WindowLevel, WindowProps};

WindowProps {
    level: WindowLevel::StatusBar,    // above floating windows, the menu bar and the Dock
    visible_on_all_workspaces: true,  // every Space / desktop, fullscreen apps' included
    non_activating: true,
    skip_taskbar: true,
    ..Default::default()
}
}

level is Normal (the default), Floating, StatusBar or ScreenSaver. always_on_top: true is Floating; it never lowers a higher level. The names are macOS’s, the one platform with several levels; elsewhere every raised level is the same “above” state.

PlatformFloatingStatusBarScreenSavervisible_on_all_workspaces
macOSNSFloatingWindowLevel (3)NSStatusWindowLevel (25)NSScreenSaverWindowLevel (1000)canJoinAllSpaces | fullScreenAuxiliary collection behavior
WindowsHWND_TOPMOSTHWND_TOPMOSTHWND_TOPMOSTno effect (no public API pins a window to every virtual desktop)
X11_NET_WM_STATE_ABOVE_NET_WM_STATE_ABOVE_NET_WM_STATE_ABOVE_NET_WM_DESKTOP = 0xFFFFFFFF and _NET_WM_STATE_STICKY, set before the map
Waylanda layer-shell window: top layer; a toplevel: no effectlayer-shell: overlay layerlayer-shell: overlay layerno effect

On macOS a window shows over another app’s fullscreen window only with visible_on_all_workspaces (fullScreenAuxiliary lets it into that app’s Space) and a raised level. Overlays that do this on macOS are usually non-activating panels, which is what non_activating: true makes; an ordinary window with the same settings has not been tried on a Mac. Keeping the app out of Cmd-Tab and the Dock is the activation policy (App::activation_policy(ActivationPolicy::Accessory)), not a window setting. On X11 a window with visible_on_all_workspaces is created hidden and shown after the hints are set. All of these come from the props whenever the window is created, so hiding and showing it again keeps them.

Window identity on Linux (app_id and WM_CLASS)

On Wayland a window’s app_id is WindowProps::app_id, or the executable’s file name when that is not set. On X11 an explicit WindowProps::app_id is the window’s WM_CLASS (instance and class) since #1519; before, X11 never saw it. Without one, WM_CLASS is unchanged: winit’s default, argv[0]’s file name, with RESOURCE_NAME as the instance when it is set. The two defaults can differ (a binary run through a symlink has argv[0] naming the link), so a .desktop file whose StartupWMClass must match on both should set WindowProps::app_id.

Wayland layer-shell overlays

A plain xdg-toplevel cannot refuse keyboard focus or a taskbar entry: the compositor decides (KWin decides whether to activate a window when it is mapped, and ignores a request to show it inactive). So on native Wayland a non_activating window is not an xdg-toplevel: it is a wlr-layer-shell surface (issue #1523) — the kind of surface panels, docks and on-screen indicators are. It never takes the keyboard (keyboard interactivity none), has no taskbar entry, and sits in the top layer, above ordinary windows — or in the overlay layer, above panels and fullscreen windows, when its level is StatusBar or ScreenSaver. KWin, wlroots compositors (sway, labwc), Hyprland, niri and COSMIC support it.

WindowProps::wayland_layer_shell: Option<bool> decides: None (the default) makes a window a layer surface when it is non_activating; Some(true) makes any window one (a focusable overlay, whose keyboard is then on-demand: it takes focus when clicked); Some(false) never does. skip_taskbar alone does not: a decorated tool window that only wants no taskbar entry keeps its decorations and compositor move. Under App::prefer_x11(true) the app runs on XWayland and the X11 column applies instead.

This changed what a non_activating window is on KWin and wlroots: before #1523 it was an ordinary window there (with a warning). A layer surface has no server decorations, title bar, minimize, maximize or fullscreen, and the compositor does not move or resize it. rinch does, so the app’s own controls keep working:

  • Position. The surface is anchored to its output’s top-left corner and placed by its margins: WindowProps::x/y, set_current_window_position and current_window_position are logical pixels relative to its output, not the desktop. With no position it is not anchored and the compositor centres it; current_window_position answers None until it is moved.
  • Output. WindowProps::output names the output by its connector name ("DP-1", "eDP-1", as wlr-randr or kscreen-doctor -o list them); set_current_window_output(Some("HDMI-A-1")) moves it there (the surface is made again, at the same position and size). An unknown name, or None, leaves the choice to the compositor.
  • Size. set_current_window_size and WindowProps::width/height set it.
  • Move and resize by the user. A data-drag-window region and resize_inset edges work as on any borderless window: rinch follows the pointer until the button is released and moves or resizes the surface itself. From a handler of your own, call start_window_drag() or start_window_resize(edge) (with rinch::platform::ResizeDirection) on the press; on an ordinary window they ask the window manager for its interactive move or resize. Called with no button held (from a timer, or after the press already ended) they do nothing, on every window.
  • Hide and show keep the position and size, as for any window, and showing it takes no focus.
  • Its output going away (unplugged, or turned off) makes the compositor close a layer surface. rinch does not take that for a close: it makes the window again on an output the compositor picks (the output name is dropped until set_current_window_output names one again), at the same position and size, or, with no output left, as soon as one appears.

current_window_surface_kind() answers what the window got: Some(WindowSurfaceKind::LayerShell), or Some(WindowSurfaceKind::Toplevel) on a compositor without layer shell — GNOME/Mutter, where the window is an ordinary xdg-toplevel as before and rinch logs one warning. There, two ways to live with it:

  • App::prefer_x11(true) builds the event loop on X11 whenever an X server is reachable (DISPLAY is set), which under a Wayland session means XWayland, where the X11 column above applies. The backend is chosen once per process, so this is an application option, not a window one. It changes the whole app’s backend (fractional scaling and input go through XWayland).
  • Create the overlay once and keep it mapped. Since activation is decided at map time, create the overlay at startup, keep it on screen (fully transparent while idle) and click-through, and change its content rather than hiding and showing it per use. What remains is a taskbar entry.

The layer role comes from rinch’s winit fork (WindowAttributesWayland::with_layer_shell); the renderers, input and scaling are those of any window. examples/wayland-overlay is a bar to try it with.

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 the app’s main window, use current_window_position / current_window_size.

Note: the WindowHandle / WindowState API below belongs to the programmatic window API (open_window, WindowBuilder), which the desktop shell does not process yet: no window is opened from it and no state is recorded for it, so get_window_state answers None.

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

Rendering Backends

Rinch has two rendering backends. Every desktop build carries the software one; the gpu feature adds the GPU one beside it, and a gpu build chooses between them at run time, when the window opens.

BuildRenderers it carries
features = ["desktop"]Software only
features = ["desktop", "gpu"]GPU and software, chosen at run time

GPU (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 (always available on desktop)

Windows are rendered using tiny-skia (CPU rasterizer) and presented via softbuffer. It is the only renderer of a build without the gpu feature, and the fallback of a build with it. This provides:

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

Choosing the renderer at run time

On a gpu build, App::renderer picks which renderer presents the window:

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

App::new(app)
    .renderer(Renderer::Auto) // the default
    .run();
}
RendererWhat it does
Auto (default)Presents on the GPU. When the GPU will not start (no adapter, no surface, no device, or a validation error while setting them up), logs a warning and presents with the software renderer instead
GpuPresents on the GPU, and panics when the GPU will not start
SoftwarePresents with the software renderer without touching the GPU

On a build without gpu there is only the software renderer, whatever you choose; asking for Renderer::Gpu there logs a warning.

RINCH_RENDERER overrides the app’s choice, so a user can force a renderer without a rebuild:

RINCH_RENDERER=cpu ./my-app      # software
RINCH_RENDERER=gpu ./my-app      # GPU, panic if it will not start

It accepts auto, gpu, software, and cpu (an alias of software), trimmed and in any case. An empty value is ignored, and any other value is logged as a warning and ignored, so the app’s own choice applies.

An app that configures the GPU device itself always presents on the GPU. App::gpu_config and App::external_gpu exist so the app can build its own pipelines on rinch’s device (see Game Engine Integration), and that app needs the device gpu_handle() hands it. So for such an app:

  • there is no fallback: a GPU that will not start panics at startup, even under Renderer::Auto;
  • a Software choice, whether from App::renderer or from a user’s RINCH_RENDERER=cpu, is logged as a warning and ignored.

Everywhere else, gpu_handle() returns None for the whole session while the software renderer presents, so code that reads it should handle None.

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.

The builder API itself needs no windowing at all. Two targets have no native menu bar to attach a menu to — Linux, where muda wants a GTK window, and the browser, where there is no window menu — so both render the same menus out of DOM nodes instead. On the desktop that is automatic; on the web it is rinch_web::mount_with_menu_bar, covered in Running on WASM.

Native Menus

Add a native menu bar with App::menu:

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

    App::new(app)
        .title("My App")
        .size(800, 600)
        .menu(vec![("File", file_menu)])
        .run();
}

For apps without menus, just leave .menu(...) off. .menu() composes with every other builder method, so a menu bar and custom window props (borderless, transparent, an icon) go on the same chain — which no run_* signature could express.

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
checkbox(label, signal)Signal<bool>A checkbox item; see below
radio(label, signal, value)Signal<T>, TOne option of a radio group; see below
checked()Some(state) for a checkbox or radio item, None otherwise

Checkbox and radio items

A checkbox or radio item is built over a Signal, and the signal is its state in both directions (issue #1520):

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

let sounds = Signal::new(true);
let speed = Signal::new("fast");

let menu = Menu::new()
    .item(MenuItem::checkbox("Play sounds", sounds))
    .separator()
    .item(MenuItem::radio("Fast", speed, "fast"))
    .item(MenuItem::radio("Accurate", speed, "accurate"));

// Read it back:  sounds.get(), speed.get()
// Set it:        sounds.set(false) moves the check mark in every menu built
//                from these items.
}
  • Activating the item (a click, its shortcut, a tray menu) flips a checkbox’s signal, or writes a radio option’s value to the group’s signal, before its on_click callback runs, so the callback reads the new state. Activating the option that is already selected leaves it selected.
  • Writing the signal from anywhere moves the mark: in the native window menu bar and a tray menu on Windows and macOS (muda CheckMenuItems), in a Linux tray menu (ksni), and in the DOM menu bar on Linux and in the browser (a rinch-app-menu-entry__check mark before the label, with role="menuitemcheckbox"/"menuitemradio" and aria-checked).
  • Every item built over one signal is one radio group. Keep a group’s options next to each other: the Linux tray draws a run of adjacent options of one group as a single radio group.
  • The item follows the same lifetime rule as a callback: built during a render, it belongs to that component, and once the component unmounts (freeing a signal it created) the item stops toggling and its mark stops following.

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.

Callback lifetime

A callback belongs to the component that created it — the scope that was rendering when you called on_click, which is where the closure captured its Signals. When that component unmounts its signals are freed, so the item stops firing rather than reading freed state (reading a freed signal panics). The callback also runs inside that component, so a Signal it creates belongs there too.

Ownership is per item, not per menu: one Menu may collect items contributed by several components, and each item’s callback stops on its own component’s unmount. This holds however the item is activated — a native menu click, a tray click, the DOM menu bar on Linux or on the web, or the keyboard shortcut.

Build the menu outside any component — from main, before App::run, which is what all the examples do — and there is no owner to record, so the callback lives for the life of the app:

fn main() {
    // Created in main(), so menu callbacks can reference them for the whole run.
    let count = Signal::new(0);

    let file_menu = Menu::new()
        .item(MenuItem::new("Reset").on_click(move || count.set(0)));

    App::new(app)
        .title("My App")
        .size(800, 600)
        .menu(vec![("File", file_menu)])
        .run();
}

A callback may rebuild the menu it was dispatched from — including registering new items and shortcuts — from inside its own handler.

Menu ids are also released when the menu that registered them goes away: building a new native menu bar releases the previous bar’s, and dropping a TrayIcon releases that tray’s after taking its icon off the panel. Keep the TrayIcon for as long as you want the tray. See System Tray for why it has to stay on the thread that built it.

A shortcut consumes the keystroke only when a callback actually runs. A chord belonging to a disabled item, to an item given a shortcut but no on_click, or to a component that has since unmounted falls through to the app instead of being swallowed — and never shadows a live duplicate of the same chord.

A shortcut with no Ctrl, Cmd or Alt leaves its key to a focused text field. While an <input> that takes text, a <textarea>, the rich-text Editor (read-only included) or a custom text target (a register_focus_target entry with on_ime) holds the keyboard, a chord such as "/", "N", "Shift+/", "Space" or "Delete" matches nothing: the field types or edits with the key, and the item fires once focus is elsewhere. The keys that yield are letters, digits, punctuation, Space, and Enter, Backspace, Delete, Home, End, PageUp, PageDown and the arrows. Escape, Tab and F1–F12 do not, so a bare "F5" item still fires from inside a field, and neither does any chord holding Ctrl, Cmd or Alt. A checkbox, radio, range or button <input> is not a text field. In the browser, “a text field” is the element the key event is dispatched at (composedPath()[0], so a field inside a shadow root counts), and an element with contenteditable counts. The rule covers rinch’s own chord matching only: on macOS and Windows the native menu also installs the item’s accelerator, which the operating system matches before rinch sees the key (issue #1284).

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

SpellingmacOSWindows / Linux
Ctrl, Cmd, Control, Meta, CmdOrCtrl, Command, Super, CommandOrControl (also CommandOrCtrl, CmdOrControl)one modifier: the native menu shows ⌘; the chord fires with Command or Control heldCtrl; the chord also fires with the Windows/Super key held
Alt, OptionOptionAlt
ShiftShiftShift

rinch has no way to bind Control and Command separately on macOS: "Ctrl+K" and "Cmd+K" are the same shortcut.

Modifiers come first and the key last, and a shortcut has exactly one key: "Ctrl+Shift+C+A", "Ctrl+N+Shift" and an unknown modifier such as "Hyper+N" are not shortcuts (they warn, below).

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
  • /, Slash · ,, Comma · ., Period · ;, Semicolon · ', Quote
  • [, BracketLeft · ], BracketRight · \, Backslash · `, Backquote

A shortcut names a key, not the character it types. A character that needs Shift is spelled as its key plus Shift: "Ctrl+Shift+/", not "Ctrl+?". (Chrome 153 reports that keystroke as key: "?", code: "Slash", shiftKey: true.) The same holds for Plus, which is the = key without Shift. Punctuation and digit chords are always matched by the physical key and the modifiers held — the desktop by winit’s KeyCode, the browser by KeyboardEvent.code — named by where it sits on a US layout; on another layout such a chord follows the key position, not the printed character.

A letter chord follows the character, not the physical position (issue #1170). "Ctrl+Z" fires from whichever key is labelled Z, wherever the active keyboard layout put it — QWERTZ’s Y/Z swap, AZERTY’s A/Q swap — not necessarily from the US physical KeyZ position. Desktop reads this from winit’s key_without_modifiers (ignoring Shift and Caps Lock); the browser reads KeyboardEvent.key, lowercased. When the active layout types no Latin letter at all for the pressed key — Cyrillic, Thai, Armenian, … — the chord falls back to the physical key instead, which is the only way such a layout can reach Ctrl+C/Ctrl+V at all (the same fix GNOME/GTK have discussed for their own non-Latin-layout bug). Digit and punctuation chords are never affected by any of this.

An app (or a user) who wants shortcut positions to stay fixed regardless of the active layout can turn the character matching off: App::shortcut_matching(ShortcutMatching::Physical) on desktop, or rinch_web::set_shortcut_matching(ShortcutMatching::Physical) before mounting on the web — the same escape hatch VS Code’s keyboard.dispatch setting and JetBrains’ “use national layout for shortcuts” toggle offer. It affects only rinch’s own chord matching; see the note on macOS/Windows below.

On macOS and Windows, a window menu bar’s item stops going through rinch’s own chord matching once its native accelerator has actually attached to the window — the OS then resolves and matches the accelerator muda installs for it (NSMenu on macOS, an accelerator table on Windows) ahead of rinch ever seeing the key, so matching it a second time would risk firing the callback twice. That “actually attached” is a recorded fact, not a guess from which platform the build targets: a window whose handle was not a Win32 one, whose accelerator table failed to install, or that never attaches a native bar at all (a borderless window using the DOM bar instead) keeps matching through rinch’s own registry, so its shortcuts are never silently unreachable. ShortcutMatching::Physical has no effect on an item once its accelerator has attached: the OS, not rinch, decides what counts as “the Z key” there, following its own per-layout accelerator resolution (character- based on both platforms, like rinch’s default). Linux has no such OS-level menu-accelerator integration and never attaches one, so its native bar, every system-tray item, and the DOM menu bar (the browser, and Linux’s own in-app bar) all go through rinch’s chord matching — and the override — exactly as described above. See issue #1284 for a related, unmeasured gap: a modifier-less chord (chord_yields_to_text_input above) may still be taken by a macOS/Windows native accelerator from a focused text field, because that check runs inside rinch’s own matching, which an attached item skips.

A shortcut string that names no key ("Ctrl+?", a typo) registers no chord and logs one tracing warning per distinct string. The DOM menu bar (Linux, the browser) still prints the string beside the item; the native menu on macOS and Windows derives its accelerator from the same parse as the chord, so it shows one exactly when a chord is armed.

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.

In a browser a few of them are the browser’s own and cannot be claimed — see Running on WASM. It changes nothing about the declaration; the desktop build still gets the chord.

Platform Behavior

macOS

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

Windows

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

Linux

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

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

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

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

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

The bar behaves like a native one: click a top-level label to open its menu, then move across the bar to switch between menus without clicking again. Hover or click a submenu row to open its flyout, click an item to run it, and click anywhere else in the window — or press Escape — to dismiss.

Escape rides the same dismiss stack every other rinch overlay uses, so it is LIFO against Modal, Drawer, Popover and an open <select>: the bar registers at mount, which puts every later overlay above it, and its handler declines the key outright while no menu is open.

That last one is a full-window overlay rinch renders under the open menu, at z-index: 199 against the bar’s 201. It is position: fixed, so it covers the window whichever of the three menu-bar layouts built it — including the title bar, so clicking there dismisses too.

It was position: absolute between issues #527 and #324. Two z-indexes in different stacking contexts are never compared; BorderlessWindow’s container was a stacking context purely because of its overflow: hidden; and a fixed box was hoisted clear out to the body, past everything between. So the overlay’s 199 and the menu’s 201 were never compared with each other, and the overlay covered the very menu it sits beneath. Both halves are fixed: overflow no longer creates a stacking context, and a fixed box now stops at its nearest ancestor stacking context — so the 199 and the 201 meet in one sequence.

To put an overlay of your own above the menu bar, render it with a z-index above 201. Since #324 that is the whole rule, in either window type: the bar’s 201 and your overlay’s number are compared in one sequence whether or not there is an overflow: hidden container between them. It used not to be — a root-level overlay behind a borderless window’s container sat above the bar at any z-index, because the two numbers were never compared at all. Code that relied on that asymmetry needs a real z-index now.

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

On Android there is no right button, so a long press stands in for it: a finger held still for 500ms — ViewConfiguration.getLongPressTimeout(), the same deadline the platform’s own widgets use — synthesises the same event through the same dispatch. The press must stay within 8dp to count; moving further makes it a scroll instead, and lifting before the deadline makes it a tap. A long press that fires the menu does not also fire onclick, so a target can safely carry both.

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

    App::new(app)
        .title("My App")
        .size(800, 600)
        .menu(vec![
            ("File", file_menu),
            ("Edit", edit_menu),
            ("Help", help_menu),
        ])
        .run();
}

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.

An <img> is sized as a browser sizes a replaced element: with no CSS size it is its natural size — display: block included, so margin: 0 auto centres it rather than it filling its container — and a lone width or height (or a min-*/max-* clamp) gives the other dimension through the image’s aspect ratio. A flex container still stretches an <img>, as in a browser; a grid container also still stretches it, where a browser does not (#1280). An image not yet loaded has no natural size: with no size from anywhere it is 0x0.

The width and height attributes reserve the box before the image arrives, as in a browser (#684):

#![allow(unused)]
fn main() {
img { src: "photo.jpg", width: "640", height: "480" }
}

Each is a length in CSS pixels ("640") or a percentage ("50%"), and is a presentational hint: it has less weight than any declaration in your own CSS, so a stylesheet rule or an inline style for width/height wins (it still outranks the built-in browser-default sheet). Together they also give the image an aspect ratio until it loads, so the common responsive reset img { width: 100%; height: auto } keeps the right height from the first frame; once the image has loaded its own ratio is used. That ratio cannot be switched off: desktop reads no aspect-ratio property (#1286), so aspect-ratio: auto on such an image changes nothing, where a browser would drop the mapped ratio. And an image that has not loaded or failed reserves its attributes’ box even with alt text, where a browser lays the alt text out instead. <video> reads the same two attributes with the same ratio, and <iframe> reads them with no ratio. The other legacy attributes (hspace, vspace, border, align, <hr width>, a table’s width/bgcolor/…) still do nothing on desktop (#1419).

Background layers

A background image — a url(...) or a linear-gradient/radial-gradient — is sized, placed and tiled the way a browser does it, for the first layer of the list (#468):

  • background-size: lengths, percentages, auto, cover, contain. An image’s auto is its intrinsic size; a gradient’s is the positioning area.
  • background-position (and -x/-y): lengths, percentages, keywords and edge offsets (right 10px top 0). A percentage is a share of the area less the tile, so 100% puts the tile’s far edge on the area’s.
  • background-repeat: repeat, no-repeat, repeat-x, repeat-y. space and round are painted as repeat.
  • background-origin: padding-box (the default), border-box, content-box. background-clip is not read, so the painting area is always the border box.
  • background-color is painted under the image.

background-position is animatable, by transition and @keyframes alike; it moves paint only, never layout. This is what makes the striped Progress bar’s stripes move and the Skeleton pulse.

A single tile that covers the whole box (background-size: cover, or a gradient at auto) is one draw. Any other layer that repeats (on either axis) is painted as one repeating pattern, whatever its tile count: its tile is rasterised once, at the size it covers on the device, and cached until the image, gradient or tile size changes. A tile that is a fractional number of device pixels (10px at 125% scaling) is rasterised at the next whole size and sampled back down smoothly; an axis that does not repeat clips the pattern to its one-tile strip. A build without the software painter (embed) has nothing to rasterise a tile with and draws one fill per visible tile instead. No layer is ever dropped for having too many tiles.

One known difference from browsers (tracked separately as #1151): rinch places a tile on whole device pixels, rounding a fractional background-position (and a fractional translate) to the nearest one. Chrome keeps the fraction and blends the pixel on the tile’s edge. The result is within one device pixel of Chrome’s, but a background-position animation — the striped Progress bar — moves in one-device-pixel steps in rinch where Chrome’s moves smoothly.

An image used to be stretched over the element’s border box whatever its background-size; one that relied on that now tiles at its natural size. background-size: 100% 100% keeps the stretch on a box without a border; on a bordered box add background-origin: border-box, since 100% is otherwise the padding box, and the tile repeats under the border.

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

An app that keeps pictures somewhere only it can reach (embedded assets, a blob store, an authenticated download) answers for a URL scheme of its own. Sources with that scheme go to the app’s loader; every other source still goes to rinch’s default loader (files, plus HTTP(S) with image-network), so there is nothing to forward:

use rinch::image::ImageLoadResult;
use rinch::prelude::*;

fn main() {
    App::new(app)
        .image_scheme("asset", |src: &str| match load_from_assets(src) {
            Ok(bytes) => ImageLoadResult::Loaded(bytes),
            Err(e) => ImageLoadResult::Failed(e.to_string()),
        })
        .run();
}

// Anywhere in the UI:
// img { src: "asset:logo.png" }

rinch::image::register_image_scheme(scheme, loader) is the same registration without the builder. A loader is any Fn(&str) -> ImageLoadResult + Send + Sync closure, or a type that implements rinch::image::ImageLoader.

What a loader must know:

  • It returns encoded bytes: the contents of a PNG, JPEG, GIF or WebP file. rinch decodes them. It never returns decoded pixels.
  • It runs on a background thread spawned for that one load, never the UI thread, so it may block on I/O. Several loads run at once.
  • Its answer is cached by source string, a failure included. A source that failed is not asked for again on its own.
  • On desktop, a panic is a failed load. It is caught on the load’s thread and cached as a failure carrying the panic’s message, so reload_image can ask again. In the browser (and on a native build with panic = "abort") a panicking loader or resolver aborts the whole app.
  • The data scheme cannot be registered (register_image_scheme("data", ..) panics). No app loader is ever offered a data: URL; an <img>’s is decoded in place.
  • A registration lasts until unregister_image_scheme. It is process-wide and is not undone when the component that made it unmounts.

A picture that arrives later

When the bytes may not exist yet (still downloading, not synced), answer ImageLoadResult::Failed straight away rather than parking the thread, and tell rinch when they are there:

#![allow(unused)]
fn main() {
// From any thread, once "asset:photo-42" can be loaded:
rinch::image::reload_image("asset:photo-42");
}

Every <img> and background-image naming that source, in every window, loads it again and takes its size. The same call refreshes a picture whose bytes changed under an unchanged source: the old picture stays on screen until the new one has decoded.

  • Pass the source exactly as the element spelled it. The source string is the key, scheme case included: reload_image("asset:photo-42") does not reach an element whose src is Asset:photo-42.
  • Reloads of one source are ordered and coalesced. A reload asked for while a load of that source is still out (its first load or an earlier reload) starts nothing at once: the answer on its way is dropped when it lands and the source is loaded once more. So the picture shown is one the loader was asked for after the last reload_image, and ten reloads during one slow load cost one more load, not ten.
  • Do not call reload_image(src) from inside the loader’s answer for src: each call discards the answer being given and asks again.

In the browser

The same two calls work in a rinch-web build. The browser loads an <img> itself, so there the loader’s bytes are wrapped in an object URL (blob:…) and that is what the element shows. The loader is called on the browser’s one thread, so it must answer from what it already holds (Failed for “not yet”, then reload_image).

The app is never asked from inside a DOM write, so a resolver or loader may write signals. An element given a source with no remembered answer shows nothing until a microtask asks for it, once per source, before the next paint; sources given while a root mounts are asked for before mount_into returns. reload_image asks at once.

An app that already has a URL to show (it made the object URL itself), or whose state lives in Rcs a Send + Sync loader cannot capture, uses the browser-only form instead:

#![allow(unused)]
fn main() {
rinch_web::register_image_url_scheme("asset", move |src| {
    store.borrow().object_url(src)   // Some(url), or None for "not yet"
});
// later:
rinch::image::reload_image("asset:photo-42");
}

Either way the element keeps the source it was given: it is in data-rinch-src, NodeHandle::get_attribute("src") answers it, and an editor document’s image node never holds the resolved URL. While the answer is “not yet” the element has no src at all. A reload reaches every <img> rinch was given that source, including one in a branch that is hidden right now, and never an <img> the page made outside rinch. It asks nothing for a source no element was given, and forgets its answer. To make rinch forget a URL you revoked, call reload_image and answer None: pictures already shown stay, and an element given the source later shows nothing until the next reload. “Not yet” is remembered per source in the browser as on desktop: only reload_image asks again, never an element that is given the source again. <img> markup set with set_inner_html is resolved like any other. Only <img src> is resolved in the browser; a background-image: url(…) there is the browser’s own.


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

Reading without freezing the UI

A clipboard read is a request to whichever application owns the clipboard. On X11 that application can be hung, and the read waits up to four seconds for it; the browser cannot be read synchronously at all. So paste_text() called straight from an event handler — as in the example above — can block the UI thread: no repaint, no input, and on some window managers a “not responding” badge (issue #149).

Every read therefore comes in three shapes, on every platform:

FunctionBlocks the caller?Use for
paste_text()yes, indefinitelyscripts, background threads, existing code
paste_text_timeout(Duration)yes, bounded — Err(TimedOut) after thatan interactive path that can accept a bounded hiccup
paste_text_async(callback)noan interactive path that must stay responsive

paste_html / paste_image have the same three. paste_rich resolves text/html → bitmap → text/plain in one read and answers with a RichPaste, so a rich-paste consumer never stacks three worst-case waits. paste_rich_with_text (and its _timeout / _async) answers the same plus the text/plain offered beside an html answer, (RichPaste, Option<String>), still in one read — it is what the built-in editor’s Ctrl+V uses, so the editor’s paste hook (Plugin::handle_paste) can tell a pasted URL by its text even when the copy also carried html.

On native, all of them are served by a single clipboard worker thread that owns the system clipboard. That is what makes the timeout worth having: giving up does not cancel the request, it only stops waiting for it, so an abandoned read finishes on the worker instead of wedging every later caller behind a lock.

Which thread does the callback run on? Not necessarily the UI thread — on native it is the clipboard worker, which is why the callback must be Send. rinch UI state is thread-local, so hop back before touching it:

#![allow(unused)]
fn main() {
use rinch::clipboard::{paste_text_async, ClipboardResult};
use rinch::prelude::*;

let text = Signal::new(String::new());
button {
    onclick: move || {
        // `Signal::send` marshals to the UI thread from anywhere.
        paste_text_async(move |result| {
            if let Ok(pasted) = result {
                text.send(pasted);
            }
        });
    },
    "Paste"
}
}

For a !Send continuation (an EditorHandle, an Rc), park it on the UI thread and send only its id across:

#![allow(unused)]
fn main() {
let id = rinch_core::park_main_callback::<ClipboardResult<String>>(move |result| {
    // Runs on the UI thread; free to touch the DOM and any Rc-based handle.
});
paste_text_async(move |result| {
    rinch_core::run_on_main_thread(move || rinch_core::resume_main_callback(id, result));
});
}

The built-in fields already work this way. Ctrl+V — and the context menu’s or Android toolbar’s Paste — in the rich-text editor and in a plain <input> or <textarea> returns at once, reads on the worker, and inserts when the read answers (issue #149 for the editor, #328 for the plain fields). A plain field takes the paste at its current selection if it still holds the keyboard from the same focus gesture; if focus moved on while the read was in flight, the paste is dropped rather than aimed at a field the user did not paste into. A read-only field starts no read at all.

Web: reaching content copied outside the app

The browser has no synchronous system-clipboard read, so on wasm32 paste_text() answers from an internal buffer. rinch-web fills that buffer from the document’s paste ClipboardEvent — the only synchronous channel to content copied in another app or tab — so a web app can paste from outside itself (issue #150). paste_text_async additionally tries navigator.clipboard.readText(), which needs a secure context and usually a user gesture, and falls back to the buffer.

Because the browser’s paste event arrives after the keydown that caused it, app paste logic on the web should hang off the paste rather than off Ctrl+V:

#![allow(unused)]
fn main() {
use rinch_core::{set_paste_interceptor, PasteEventData};

set_paste_interceptor(|data: &PasteEventData| {
    // The clipboard buffers are already filled when this runs, so
    // `rinch::clipboard::paste_text()` works here too.
    if let Some(text) = &data.text {
        insert_into_my_editor(text);
        return true; // handled: the browser should not also insert it
    }
    false
});
}

The interceptor is a single slot per document — plus a thread-global fallback for registrations made outside any dispatch, which is where every registration on rinch-web lands (like set_keyboard_interceptor; issues #340, #478) — and is dispatched by rinch-web; desktop reads the clipboard directly when Ctrl+V arrives and has no OS paste event to hang it off.

Registering it from inside a render ties it to that component: unmounting releases the slot, so an interceptor that captured a Signal can never outlive it (issue #183). Registering from main or startup code has no owner and keeps app lifetime. See Lifetimes.

Android: the text-selection toolbar and paste

A long press on an <input> or <textarea> shows Android’s own floating text-selection toolbar — Cut / Copy / Paste / Select all, the platform’s strings, the platform’s look — floating over the selected word, or over the caret when nothing is selected: the rect the runtime’s TextEditState.anchor reports (issue #813). It is an ActionMode.TYPE_FLOATING started by RinchActivity on the window’s decor view, and it is what the desktop’s built-in DOM context menu becomes on Android: the shell sets TextContextMenuPresentation::Shell, so the runtime prepares the caret and selection and hands the presentation to the platform. A data-oncontextmenu handler on the field or an ancestor still wins, as on desktop.

The long press follows the platform convention: it selects the word under the finger, a press inside an existing selection keeps that selection, and an empty field (or a press on whitespace) keeps its caret and offers only Paste. Cut and Copy appear only over a selection, Paste is hidden on a readonly field and while the clipboard is empty (hasPrimaryClip(), asked once per long press, which reads no clip and raises no clipboard-access notice), and Select all needs content. The clipboard itself is read only when Paste is tapped. Each item runs exactly the code its keyboard shortcut runs (RinchApp::perform_text_edit), so a toolbar Paste, a hardware Ctrl+V and an IME’s paste are one path.

The android feature implies clipboard. Paste is not optional on a phone, and without the feature handle_paste has nothing to read: a hardware Ctrl+V inserts nothing and an IME’s paste request is answered “done” with nothing pasted (the toolbar hides its Paste instead). Every route measured on an API 34 emulator with one Gboard build lands: the toolbar’s Paste; the clipboard chip Gboard puts on its suggestion strip after a copy; an item in Gboard’s clipboard panel; the Paste key of Gboard’s Text Editing panel; and Ctrl+V from a hardware keyboard. That Gboard build delivers its chip and clipboard-panel pastes as ordinary committed text (InputConnection.commitText), and its Text Editing panel’s Paste as performContextMenuAction(android.R.id.paste) — which BaseInputConnection would otherwise drop, since rinch’s connection keeps no Editable, and which reaches the same path through RinchInputConnection’s override.

The toolbar goes when the user taps or scrolls elsewhere, types, edits from the keyboard, cuts, copies or pastes — from the toolbar or through the IME’s own editing keys, as over a platform text view — or when the field loses focus; the activity also finishes it on its own in onPause and on window-focus loss. An item that finishes the toolbar (Cut, Copy, Paste) is finished on the Java side before the item is reported, so a refresh the shell decides after hearing of the item never re-prepares it; and a refresh never starts a toolbar — it only moves and re-prepares one that is up — so a refresh already on its way to the UI thread when the item finished the mode finds nothing to update and does nothing, where it used to start a new toolbar that nothing could take down. An IME’s Cut, Copy or Paste finishes nothing on the Java side, and the shell asks for the finish itself. (Gboard’s Text Editing panel enables its Cut and Copy only over a selection the IME can see, and rinch’s input connection reports none, so from that panel only Paste and Select all reach a rinch field — measured.) The rich-text Editor is not part of an Android build today (it is desktop-only), so the toolbar covers <input> and <textarea>.


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

Changing the Tray at Runtime

Every part of a built tray can change while it runs (issue #1520). Each setter takes &self, so a tray kept in an Rc or a thread_local! can be changed from a menu callback, a timer or an effect:

#![allow(unused)]
fn main() {
use rinch::tray::{TrayIconBuilder, TrayIconImage};

// Decode each image once; swapping is then cheap.
let idle = TrayIconImage::from_png(include_bytes!("idle.png"))?;
let busy = TrayIconImage::from_png(include_bytes!("busy.png"))?;

let tray = TrayIconBuilder::new().with_icon(idle.clone()).build()?;

tray.set_icon(&busy)?;                 // also set_icon_png / set_icon_rgba
tray.set_tooltip("Working… 43%")?;
tray.set_menu(Some(new_menu));         // or None to remove the menu
}

None of them blocks the calling thread. On Windows and macOS tray-icon applies a change on the spot. On Linux each change goes to a thread of rinch’s own, which applies it to the ksni service: a tray host that has stopped answering holds that thread, never yours. Changes made while it is held are coalesced — the newest icon, tooltip and menu win, and only the latest state of each check mark is kept — so an app swapping icons several times a second queues neither work nor memory behind a hung host. (Measured: 200 icon and tooltip swaps on a KDE tray return in about 1 ms, and the item’s tooltip then reads the last one.)

On Linux the item’s Title, which Plasma sorts the tray by, is a stable name: TrayIconBuilder::with_title(..), else the tray’s id. set_tooltip changes only the ToolTip, so the icon does not move when the text does. Windows and macOS have no title property and ignore with_title.

set_menu releases the previous menu’s callbacks at once, like a rebuilt menu bar; it is safe to call from one of the old menu’s own callbacks (“Start” replacing itself with “Stop”). To move a check mark, write the item’s signal instead: checkbox and radio items (Menus) follow their signals in a tray menu too.

Item Id

On Linux the tray item reports an id to the panel (the StatusNotifierItem Id), which panels use to remember per-item settings such as whether the icon is shown and where. It is the app’s own (issue #1529; it was "rinch-app" for every rinch app): the executable’s file name, or the WindowProps::app_id the app declared if the tray is built after App::run has started. Set it explicitly with with_id, which an app that declares an app_id and builds its tray in main should do with the same string. Rebuilding the tray after App::run has started can change its id (the declared app_id is seen from then on), so pass with_id to keep it fixed. Upgrading to this changes an existing app’s id, so a panel forgets any visibility or position the user had set for its icon, once.

#![allow(unused)]
fn main() {
let tray = TrayIconBuilder::new()
    .with_id("com.example.notes")
    .with_icon(icon)
    .build()?;
}

Windows and macOS have no such id (they identify the icon by the app), so with_id is not used there.

Left Click (Activate)

By default a primary click on the icon shows the window. on_activate replaces that:

#![allow(unused)]
fn main() {
let listening = Signal::new(false);
let tray = TrayIconBuilder::new()
    .with_icon(idle)
    .on_activate(move || listening.update(|l| *l = !*l))
    .build()?;
}

On Linux this is the StatusNotifierItem Activate request (which click sends it, and whether a panel opens the menu instead, is the panel’s choice); on Windows and macOS it is a left-button release on the icon. The callback runs on the main thread and follows a menu callback’s lifetime rule. Pass || {} for a click that does nothing.

Tray-Only Apps

An app whose whole UI is the tray starts with WindowProps { visible: false, .. } and shows its one window (“Settings…”) with show_current_window(); on macOS it also sets App::activation_policy(ActivationPolicy::Accessory) so it has no Dock icon. See Windows and examples/tray-only, which swaps icons on a timer, changes its tooltip, has a checkbox and a radio group, and opens a settings window from the menu.

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

Lifetime and Threads

On Linux, build() waits for the tray host to register the icon, which on a healthy desktop takes a few milliseconds. If the host holds its D-Bus name but never answers (a frozen kded or plasmashell), build() gives up after five seconds and returns TrayError::CreateFailed rather than hanging your app’s startup; treat that error as “no tray this session”. Do not retry in a tight loop: the timed-out registration cannot be cancelled, so each failed attempt under a still-hung host leaves a background thread and a D-Bus connection waiting on it until the host answers or goes away.

The tray lives exactly as long as the TrayIcon that build() returned. Dropping it removes the icon from the panel and then releases its menu callbacks, so an icon that can still be on screen keeps working items. On Linux the drop shuts ksni’s D-Bus service down and waits up to one second for it to close its connection, which on a healthy desktop takes a few milliseconds. If the tray host has stopped answering, the drop returns after that second anyway. The callbacks then stay registered until the service has closed, and are released by the next tray build or drop on the same thread. Keep the handle for as long as you want the tray, for example as a local in main that lives until the event loop exits: let _tray = … keeps it, while a bare let _ = … drops it at once.

A TrayIcon is neither Send nor Sync, on every platform. Keep it on the thread that built it. A static OnceLock<TrayIcon>, an Arc<Mutex<TrayIcon>>, or moving it into std::thread::spawn does not compile; a thread_local! works. This is deliberate. Menu callbacks capture Signals (which are !Send) and always run on the main thread, so they live in a thread-local registry, and dropping the tray releases them from the registry of the thread it is dropped on. Dropped on another thread, it would release nothing. On Linux this has been the case since the tray started releasing its callbacks on drop (issue #183); before that the Linux handle was Send.

A disabled item (MenuItem::new("…").enabled(false)) is shown greyed out and registers no callback. Its on_click never runs.

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

Notifications

Enable with: features = ["notifications"] (off by default; it needs no windowing feature).

rinch::notification shows an operating-system notification: a freedesktop notification on Linux (KDE, GNOME, dunst, mako), a toast on Windows, a UserNotifications banner on macOS.

#![allow(unused)]
fn main() {
use rinch::notification::{Notification, NotificationError};
use rinch::prelude::*;

let shown = Notification::new("Hotkey unavailable")
    .body("Ctrl+Alt+Space is taken by another app.")
    .icon_png(include_bytes!("icon.png"))        // optional
    .on_click(|| show_current_window())          // optional
    .on_dismiss(|why| tracing::info!("gone: {why:?}"))
    .on_error(|e: &NotificationError| tracing::warn!("{e}"))
    .show();                                     // Result<NotificationHandle, NotificationError>
}

examples/tray-only’s “Test notification” item raises one; clicking it opens the settings window.

Threads and callbacks

show() makes no IPC call on the calling thread. The D-Bus calls (Linux) and the WinRT calls (Windows) run on a worker thread started by the first show(), and macOS’s calls answer through completion handlers. The only work done in place is writing an icon_png image to a file, once per distinct image, in a directory only the user can write ($XDG_RUNTIME_DIR on Linux, else the user’s cache directory; the per-user temporary directory on macOS and Windows). Call show() on the main thread: the callbacks are !Send and stay there.

on_click, on_dismiss and on_error run on the main thread, each in one batch, like a menu callback. They follow the same lifetime rule: a callback set while a component renders belongs to that component, and does not run once it has unmounted; one set outside any render (from main, a timer) keeps app lifetime.

A click ends the notification: on_dismiss does not run after on_click. on_dismiss gets a DismissReason: Expired, Dismissed (by the user), Closed (by NotificationHandle::close) or Unknown.

Errors

show() returns Err for what is known at once: Unsupported on a platform with no backend (Android; the feature does not build for the web, see below) and on an unbundled macOS executable. What only the platform can answer arrives later through on_error, on the main thread:

  • NoService: no D-Bus session bus (or it went away), or no org.freedesktop.Notifications server on it.
  • PermissionDenied: the user refused the app notifications (macOS).
  • Platform: anything else (Windows would not show the toast, D-Bus failed).

A notification with no on_error logs the error with tracing::warn!.

App identity

The desktop files notifications under the app that sent them. rinch uses the identity the window has: WindowProps::app_id if the window sets one, else the executable’s file name (without .exe on Windows), the same derivation as the Wayland app_id. rinch::notification::set_app_id overrides it and set_app_name sets the name shown as the source (default: the app id).

  • Linux: the id goes in the desktop-entry hint, so the server can take the app’s name and icon from <app id>.desktop if it is installed. rinch writes no file for notifications. The image is passed as the image-path hint. A click needs a server with actions (KDE and GNOME have them). If the notification server restarts (plasmashell, dunst), the notifications it was showing are gone: each gets on_dismiss(DismissReason::Unknown), and a signal from the new server that reuses an old id never reaches them.
  • Windows: the id is the toast’s AppUserModelID (AUMID). Windows shows toasts from an unpackaged app only for an AUMID it knows, either from a Start-menu shortcut that carries it or from a registry key. On the first show() rinch writes HKCU\Software\Classes\AppUserModelId\<AUMID> with a DisplayName (the set_app_name name), unless that key already exists. No shortcut is needed or created. Call set_register_app(false) if your installer registers the AUMID itself. An AUMID may not contain spaces, so an executable named My App.exe needs set_app_id("com.example.MyApp"); an explicit id also keeps two unpackaged apps with the same file name apart. A click is reported while the toast is up; once it has timed out into the Action Center (on_dismiss(Expired) has run), a click there is not reported. A toast clicked in the Action Center after the app has exited does nothing: relaunching on click needs a COM activator, which rinch does not register.
  • macOS: the identity is the bundle identifier. The UserNotifications framework works only inside an .app bundle; run unbundled (cargo run), show() returns Unsupported. The first show() asks the user for permission. macOS does not report a dismissal, so on_dismiss runs there only for NotificationHandle::close. The image is attached to the notification; the app icon is the bundle’s. rinch installs its UNUserNotificationCenter delegate on the first show(): a click that launches the app is not reported, and an app or crate that sets its own delegate replaces rinch’s (one delegate per process).

Not covered

  • The web: the feature brings in tokio’s multi-threaded runtime (the workspace’s tokio), which does not build for wasm32. A web app can use the browser’s Notification API through web-sys.
  • Android.
  • Buttons other than the click itself, replies, sounds, urgency and timeouts.

Global Hotkeys

Enable with features = ["global-hotkeys"] (off by default). rinch::hotkey registers system-wide hotkeys: they fire while another application has the keyboard focus, and they report press and release, so a hold-to-talk binding works.

#![allow(unused)]
fn main() {
use rinch::hotkey::{self, HotkeySpec, HotkeyState};

let listening = Signal::new(false);
let hk = hotkey::register(
    HotkeySpec::new("Ctrl+Alt+Space").id("listen").description("Start listening"),
    move |ev| listening.set(ev.state == HotkeyState::Pressed),
)?;
hk.rebind("RightCtrl")?; // hold Right Ctrl alone
drop(hk);                // unregisters
}
  • Callbacks run on the main thread, each in its own batch, so they can set signals directly. Register on the main thread; a Hotkey is neither Send nor Sync.
  • Lifetime. A hotkey registered while a component renders belongs to that component: it is unregistered when the component unmounts and its callback does not run after that. One registered outside any render (in main) lives until its Hotkey is dropped. Dropping the handle always unregisters. This is the menu-callback rule.
  • Conflicts are errors. register and rebind return HotkeyError::Conflict when another application holds a plain chord (on the platforms that can tell, see below) or when this app already registered it. A failed rebind keeps the old binding. Rebinding while the hotkey is held delivers its Released first.
  • Auto-repeat does not press a hotkey again: a held hotkey is one Pressed and one Released, on every backend.
  • A report the backend made for a binding that has since been rebound is dropped, so a rebind never leaves the hotkey pressed.

Shortcut strings

A shortcut names physical keys, as a menu shortcut does: "Ctrl+/" is the key labelled / on a US layout, and a shifted character such as ? is an error that tells you to write Shift+/. Every key a menu shortcut accepts works here. On top of those:

  • Sided modifiers: LeftCtrl, RightCtrl (also RCtrl, ControlRight), RightAlt (AltGr), LeftShift, RightSuper. A plain Ctrl matches either key.
  • Modifier-only bindings: a shortcut whose last key is a modifier. "RightCtrl" fires when Right Ctrl is pressed with no other key held and releases when it comes up. "Ctrl+Shift" fires when Shift goes down while Ctrl is held, and releases when either comes up. Pressing Right Ctrl and then C (copy) does fire "RightCtrl": the press is reported the moment Right Ctrl goes down, because a hold-to-talk app cannot wait to find out whether a chord follows. Its Released then carries chorded: true (another key went down during the hold). Only a key that was already held when the modifier went down stops the press. Caps Lock, Num Lock and Scroll Lock never count as another key.

A toggle app (press once to start, once to stop) should act on the release and ignore a chorded one, so that Right Ctrl+C copies text instead of toggling:

#![allow(unused)]
fn main() {
hotkey::register("RightCtrl", move |ev| {
    if ev.state == HotkeyState::Released && !ev.chorded {
        listening.update(|l| *l = !*l);
    }
})?;
}

A hold-to-talk app keeps acting on Pressed and Released, and can drop what it recorded when the release is chorded. chorded is always false on the Wayland portal and for macOS plain chords, which do not see the other keys.

  • Super (also Win, Cmd, Meta) is its own modifier, separate from Ctrl. CmdOrCtrl is Cmd on macOS and Ctrl elsewhere.
  • Any W3C key code name: F13…F24, Insert, Pause, PrintScreen, Numpad1.

Matching is exact on modifiers: Ctrl+Space does not fire for Ctrl+Shift+Space. Shortcut::parse checks a string without registering it, and its Display is the canonical spelling.

Letting the user choose: HotkeyCapture

HotkeyCapture turns the keys pressed in your own window into a shortcut. Feed it every key event while a settings field is waiting, for example from a keyboard interceptor installed for the duration:

#![allow(unused)]
fn main() {
use rinch::core::{set_keyboard_interceptor, clear_keyboard_interceptor};
use rinch::hotkey::{CaptureStep, HotkeyCapture};

let capture = RefCell::new(HotkeyCapture::new());
set_keyboard_interceptor(move |k| {
    match capture.borrow_mut().feed(k) {
        CaptureStep::Pending(held) => preview.set(held),        // "Ctrl+Alt+"
        CaptureStep::Done(sc) => { let _ = hk.rebind(&sc.to_string()); clear_keyboard_interceptor(); }
        CaptureStep::Cancelled => clear_keyboard_interceptor(), // Escape alone
    }
    true
});
}

A non-modifier key completes a chord, captured with unsided modifiers (Ctrl+Alt+Space). Modifiers pressed and released with nothing else are captured as a modifier-only binding, with sides (RightCtrl). The capture reads only your app’s own key events, so it needs no permission. On the desktop it sees only the keys rinch’s desktop shell names (letters, digits, F1–F12, the editing and arrow keys, =, -, Ctrl/Shift/Alt). Other keys, Super included, are ignored there; write those shortcuts by hand.

Platforms

hotkey::backend() says which mechanism the process uses.

PlatformPlain chord (Ctrl+Alt+Space)Modifier-only or sided (RightCtrl, LeftShift+Space)
WindowsRegisterHotKey reserves it: a conflict is an error, and Windows swallows the chord (the focused app does not get it). A WH_KEYBOARD_LL hook reports press and release.The hook alone. Not swallowed (Right Ctrl still reaches the focused app), and no conflict check.
macOSCarbon RegisterEventHotKey: press and release, conflicts reported, swallowed, no permission needed.A listen-only CGEventTap, which needs the Input Monitoring permission. Not swallowed, no conflict check.
Linux, X11XGrabKey reserves it: a conflict is an error, swallowed. XInput2 raw key events report press and release.XInput2 raw key events alone. Not swallowed, no conflict check.
Linux, WaylandThe org.freedesktop.portal.GlobalShortcuts portal (KDE Plasma, recent GNOME).The same portal.

Windows. A low-level hook sees no input while an elevated (administrator) window has the focus. If a release is missed that way, a 100 ms check of the keys the hook believes are down releases the binding, so a hold never sticks.

macOS. The event tap starts only when the first modifier-only or sided binding is registered, so an app that uses only chords never asks for a permission. Without Input Monitoring that registration returns HotkeyError::PermissionMissing, and hotkey::permission() answers Permission::Missing. hotkey::request_permission() shows the system prompt. After the user grants it, register the binding again; some macOS versions apply the grant only after the app restarts.

Wayland. A Wayland compositor gives no application another application’s keyboard, so the portal is the only route, and the user decides the binding. The shortcut you pass is the preferred trigger; the desktop shows its own dialog, and remembers the user’s choice per application and id. That is why register answers Binding::Pending at once instead of waiting for the dialog, and why Hotkey::binding() later becomes Binding::SystemChosen { description }, with the desktop’s own text for the trigger, or Binding::Declined if the user dismissed the dialog. Hear the outcome with Hotkey::on_binding_change. Give every hotkey a stable id, or the desktop treats a rebound one as a new shortcut. On the portal, changing the binding is the desktop’s job: the desktop keeps the user’s choice for an id, so rebind’s new shortcut is only a preference, and it is used only for an id the desktop has no choice for yet. After a rebind, a portal of version 2 or later opens its own dialog (ConfigureShortcuts) so the user can change it there. All hotkeys share one portal session; registrations made in one turn are bound together, with one dialog. A modifier-only shortcut has no portal spelling, so it is registered with no preferred trigger and the user picks one. HotkeySpec::system_chosen(id) asks for a binding with no preference at all.

The portal files shortcuts under an app id, and it accepts only an app id that has an installed desktop entry (KDE Plasma refuses an app without one). rinch names the app with the id its window carries (WindowProps::app_id, else the executable’s name; an id without dots is accepted, checked against KDE’s registry); hotkey::set_app_id overrides that, for example when hotkeys are registered before the window exists. When no entry is installed, rinch writes one: a minimal <app id>.desktop with NoDisplay=true (so it shows in no menu) in $XDG_DATA_HOME/applications (~/.local/share/applications). It does so only when the portal refused the id for want of an entry, never when a system directory or the user’s directory already has one, and it never replaces a file. An app that installs its own entry, or must not write to the user’s home directory, calls hotkey::set_write_desktop_entry(false) before registering; without an entry, registration then fails with HotkeyError::Unsupported, and the message names the file the desktop needs.

In an X11 session (no WAYLAND_DISPLAY) rinch uses X11. RINCH_HOTKEY_BACKEND=x11 or =portal overrides the choice; X11 in a Wayland session sees only keys typed into XWayland windows.

The feature adds no crate to Cargo.lock. On Windows and macOS it adds tokio to the build (two crates; only the Linux portal uses it). On Linux it builds the D-Bus stack (zbus) and tokio, which the portal thread runs in; with file-dialogs already on, that is two more crates (tokio and its macros).


Enabling Features

Add features to your Cargo.toml:

[dependencies]
rinch = { version = "0.1", features = ["file-dialogs", "clipboard", "system-tray", "notifications"] }

Platform Support

FeatureWindowsmacOSLinuxAndroid
File Dialogs✓✓✓SAF (rinch_android::file_picker)
Clipboard (Text)✓✓✓✓
Clipboard (Image)✓✓✓*–
Text-selection toolbarDOM menuDOM menuDOM menunative (ActionMode)
System Tray✓✓✓**–
Notificationstoast✓ (in an .app bundle)✓ (freedesktop server)–
Global hotkeys✓✓✓ (X11, Wayland portal)–

* 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() {
    App::new(app).title("My App").size(1280, 720).run();
}

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(). It is None while the window
// presents with the software renderer (RINCH_RENDERER=cpu, or the fallback
// when the GPU would not start), so handle that case.
let Some(gpu) = gpu_handle() else {
    // No GPU device: submit CPU pixels through surface.writer() instead.
    return;
};
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 for compositing — pass BOTH the texture and its view.
// rinch needs the texture for the GPU→CPU readback path (inline-painted
// RenderSurface) and the view for compositing it directly.
let view = texture.create_view(&Default::default());
registrar.set_texture_source(texture, 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()).

gpu_handle() returns None until the window’s GPU renderer has started, and for the whole session when the window presents with the software renderer: Renderer::Software was chosen, a user set RINCH_RENDERER=cpu, or the GPU would not start and Renderer::Auto fell back (see Rendering Backends). An app that configures the device with App::gpu_config or App::external_gpu (below) is exempt: it always presents on the GPU, or panics at startup if the GPU will not start, so once its window is up the handle is there.

Use rinch::wgpu. rinch pins a patched wgpu fork. Construct every wgpu type you hand back to rinch (TextureView, config features/limits, …) from rinch::wgpu::…, not a separately-pinned wgpu dependency, or the types won’t match.

Sharing a High-Capability GPU Device

Zero-copy compositing requires your texture to live on the same device rinch composites with. By default rinch requests that device with Features::default() / Limits::default(). If your renderer needs more (extra features, larger storage buffers, more bind groups, …), raise the device’s requirements at startup so the shared device — the one published via gpu_handle() — can host your pipelines too.

Let rinch own the device (recommended). rinch still creates the instance, picks a surface-compatible adapter, and creates the device, so presentation is always correct — you only add the capabilities you need:

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

let gpu = RinchGpuConfig {
    required_features: wgpu::Features::FLOAT32_FILTERABLE,
    required_limits: wgpu::Limits {
        max_storage_buffers_per_shader_stage: 32, // wgpu default is 8
        ..Default::default()
    },
};
App::new(app).gpu_config(gpu).run();
}

After startup, gpu_handle() returns the shared device, queue, and adapter (use adapter.limits() to clamp, e.g. max_buffer_size = min(adapter, 2 GiB)). The requested features/limits are passed through verbatim — if the adapter can’t satisfy them, device creation fails loudly rather than silently dropping a capability.

Bring your own device. If you must keep your renderer’s exact DeviceDescriptor, build the whole GPU stack and hand it to rinch. rinch creates only the window surface (from your instance), validates that your adapter can present to it, and composites directly onto your device — no request_device, no CPU readback:

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

let gpu = ExternalGpu {
    instance,                    // rinch creates the window surface from this
    adapter: Arc::new(adapter),  // must be able to present to the window
    device: Arc::new(device),
    queue: Arc::new(queue),
};
App::new(app).external_gpu(gpu).run();
}

The provided device is published through gpu_handle(). On a single-GPU machine a headless adapter (compatible_surface: None) usually presents fine; on multi-GPU systems create the adapter from a surface-compatible one, or prefer App::gpu_config (which guarantees compatibility). See the gpu-device-config example for both modes.

Layout Size

Query the surface’s current layout dimensions (set by CSS/Taffy) to match your render resolution:

#![allow(unused)]
fn main() {
let (w, h) = surface.layout_size();
// or from the registrar:
let (w, h) = registrar.layout_size();
}

Surface Events

Events are dispatched to the handler set via set_event_handler(). Coordinates are in logical pixels relative to the surface’s top-left corner.

EventFieldsDescription
MouseDownx, y, buttonMouse button pressed
MouseUpx, y, buttonMouse button released
MouseMovex, yMouse moved over surface
MouseWheelx, y, delta_x, delta_yScroll wheel
MouseEnterx, yCursor entered surface
MouseLeave—Cursor left surface
KeyDownSurfaceKeyDataKey pressed (when focused)
KeyUpSurfaceKeyDataKey released (when focused)
TextInputStringText input (when focused)
FocusGained—Surface received keyboard focus
FocusLost—Surface lost keyboard focus

A press keeps the pointer. From a press on the surface until the release of the button that started it, every move and release of that pointer go to the surface wherever it is (coordinates then fall outside the surface’s size), and it hears no MouseLeave until then; a MouseLeave follows a release off the surface. A press or release of another button meanwhile is a chord: the surface hears it and keeps the pointer. The browser backend does this with setPointerCapture. Desktop routes the surface’s events the same way, but not the document’s: DOM hover (:hover, onmouseenter / onmouseleave) and the cursor still follow the element under the pointer during the press, where a browser routes them to the capturing canvas.

A press whose release never arrives is ended without one, and the surface hears it ended — PointerCancel with pointer events, else a MouseUp where the cursor is: on desktop when the window loses focus, when the same pointer presses the left button again (another finger’s press proves nothing), on a platform PointerCancel (Android’s touch-scroll takeover), and silently when the surface unmounts; in the browser on pointercancel.

Pen, touch and pinch: pointer events (opt-in). A drawing surface wants to know which device pressed and how hard. surface.set_pointer_events(true) replaces MouseDown / MouseMove / MouseUp with these (every other surface hears exactly what it did before):

EventFieldsDescription
PointerDownx, y, button, pointerA button, pen tip or finger went down
PointerMovex, y, pointerIt moved (or its pressure changed)
PointerUpx, y, button, pointerThe press ended
PointerCancelpointerThe platform took the pointer away mid-press; no PointerUp follows
Pinchx, y, scaleTwo touches spreading or closing, a trackpad pinch, or Ctrl+wheel; scale multiplies the zoom (above 1 is in), relative to the last Pinch

SurfacePointer is { id, kind, pressure, primary }: kind is Mouse, Pen, Eraser, Touch or Unknown; pressure is 0.0..=1.0, and a device that cannot measure it reports 0.5 while down and 0 otherwise (the Pointer Events rule); id tells pointers that are down at once apart (two fingers) and stays the same from a press to its release. Compare ids for equality only: their values are the platform’s. On desktop the mouse is 1, a tablet tool 2 and each finger an id of its own above both; in the browser id is the event’s pointerId, whose value for a mouse differs between engines. What each host reports:

  • Browser: Pointer Events (pointerType, pressure, pointerId, isPrimary, the eraser button); two touches pinch; Ctrl+wheel (a trackpad pinch) pinches.
  • Desktop: winit’s pointer source (a tablet tool’s force, a touch’s force where measured); two touches pinch; a trackpad pinch (macOS, iOS, Wayland) and Ctrl+wheel pinch. One move per pointer per frame, as the browser does without getCoalescedEvents.
  • Android: every finger is folded into one mouse pointer (id 1, kind Mouse) by the touch translation, so there is no Touch kind and no two-finger Pinch.
  • Embed (RinchContext): the host feeds plain mouse events, so every pointer is the mouse (Mouse, id 1); Ctrl+wheel still pinches.

SurfaceKeyData contains key, code, ctrl, shift, alt, meta. key is spelled like the browser’s KeyboardEvent.key on both backends, so the space bar is key == " " and code == "Space".

A focused surface only swallows the keys it claims (issue #482). set_event_handler keeps receiving every KeyDown/KeyUp exactly as above — that delivery is unaffected. set_key_handler(handler: impl Fn(&SurfaceKeyData) -> bool) is a separate, additive registration asked after: should this key stop at the surface? Leave it unset — the default — and the surface claims nothing, so a host’s own window-level keybindings (and, on the web, the browser’s own reload/find/… shortcuts) keep working the moment the canvas is focused, and on desktop so do DevTools (F12), inspect mode (Alt+I) and Tab:

#![allow(unused)]
fn main() {
surface.set_key_handler(|key| matches!(key.code.as_str(), "KeyW" | "KeyA" | "KeyS" | "KeyD" | "Space"));
}

The document-level keyboard interceptor (set_keyboard_interceptor) is unaffected either way — it already sees every key before a focused surface does, on both backends.

On the web, an unclaimed key also keeps the browser’s default action — no longer preventDefault()ed just because the surface is focused, matching an unfocused <canvas>. Space, PageUp/Down and the arrow keys can scroll the page, and Tab leaves the canvas. A web game steering with any of those must claim them via set_key_handler, or the page scrolls under it.

Web (canvas viewport)

The same RenderSurface + create_render_surface() API works on rinch-web, but the model is inverted. On the web the browser composites, so rinch only creates and manages a real <canvas> “viewport hole” sized by layout; the app owns the GPU context (rinch links no wgpu on web). This mirrors desktop symmetrically: desktop = rinch owns the window and you submit frames; web = you own the canvas surface.

#![allow(unused)]
fn main() {
use rinch::prelude::*;
use rinch::render_surface::{create_render_surface, RenderSurface};

let surface = create_render_surface();

// After mount, grab the <canvas> and create your own wgpu WebGPU surface on it.
// (canvas_element() returns None until the component has mounted.)
if let Some(canvas) = surface.canvas_element() {
    let wgpu_surface = instance
        .create_surface(wgpu::SurfaceTarget::Canvas(canvas))?;
    // request adapter/device (async on web), configure, and render each frame.
}

// Resize notification (ResizeObserver-driven). Size is PHYSICAL px
// (CSS px × devicePixelRatio), matching desktop — HiDPI-correct out of the box.
surface.set_resize_callback(move |w, h| reconfigure_wgpu_surface(w, h));

// Per-frame tick (drives requestAnimationFrame on web).
surface.set_render_callback(move |_writer, w, h| render_triangle(w, h));

rsx! { div { style: "flex:1;", RenderSurface { surface: Some(surface) } } }
}

Notes:

  • HiDPI out of the box. layout_size() reports physical pixels (CSS px × devicePixelRatio), rinch sizes the canvas backing store to match, and set_resize_callback pushes the new physical size on every resize so you can reconfigure your wgpu/WebGL surface.
  • Input (pointer / wheel / keyboard incl. KeyUp / focus) over the canvas is delivered to set_event_handler — rinch does not swallow it. Click the canvas to focus it for keyboard events.
  • CPU path still works & is portable. SurfaceWriter::submit_frame blits via a lazily-created 2D context; it self-disables once you claim a WebGPU/WebGL context. So the same app can run on desktop and web.
  • Clean teardown. The ResizeObserver and canvas listeners are removed when the component unmounts — no leaks.
  • See examples/webgpu-surface-web (a WebGPU triangle in a rinch DOM UI) — the web counterpart of examples/game-embed. Build with trunk serve over localhost (so navigator.gpu is available; the webgl cargo feature is a fallback).

API Reference: RenderSurface

Function / TypeDescription
create_render_surface()Create a new surface handle
RenderSurfaceHandleMain handle — set event handler, get writer/registrar
SurfaceWriterThread-safe CPU pixel submission (Send + Sync + Clone)
GpuTextureRegistrarThread-safe GPU texture registration (Send + Sync + Clone)
RenderSurfaceComponent — use in RSX with surface: Some(handle)
SurfaceEventInput event enum dispatched to handler

RenderSurfaceHandle methods:

MethodDescription
writer()Get a SurfaceWriter for CPU pixel submission
gpu_registrar()Get a GpuTextureRegistrar for GPU texture compositing
set_event_handler(handler)Set input event callback (main thread closure)
set_key_handler(handler)Fn(&SurfaceKeyData) -> bool; whether a key already delivered to set_event_handler should stop here instead of continuing to a window-level host keybinding (web) or DevTools/inspect/Tab (desktop). Unset: claims nothing (issue #482)
set_render_callback(cb)Per-frame FnMut(&SurfaceWriter, w, h) — drives requestAnimationFrame on web
set_resize_callback(cb)FnMut(w, h) fired on backing-size change (physical px) — reconfigure a GPU surface
layout_size()Get current physical (width, height) (web: CSS px × devicePixelRatio)
canvas_element()(web only) the <canvas> after mount — create a WebGPU/WebGL context on it
set_texture_source(texture, view, w, h)(desktop, gpu) Set GPU texture directly (main thread only)
has_texture_source()Check if a GPU texture is registered
id()Surface ID
viewport_name()Internal viewport name

Embed API

The embed API is for when your game owns the window and wgpu device. Rinch runs headless — you feed it platform events, it produces a Vello scene, and you render/composite it yourself.

Enable with: features = ["desktop"]

Quick Start

use rinch::prelude::*;
use rinch::embed::{RinchContext, RinchContextConfig, RinchOverlayRenderer};

#[component]
fn game_hud() -> NodeHandle {
    let health = Signal::new(100);
    rsx! {
        div { style: "position: absolute; top: 10px; left: 10px;",
            Text { size: "lg", color: "white", {|| format!("HP: {}", health.get())} }
        }
    }
}

fn main() {
    // Your game creates the window and wgpu device
    let (device, queue, surface, window) = my_engine::init();

    // Create rinch UI context
    let mut ctx = RinchContext::new(
        RinchContextConfig {
            width: 1280,
            height: 720,
            scale_factor: window.scale_factor(),
            theme: None,
        },
        game_hud,
    );

    // Create overlay renderer from your device
    let mut overlay = RinchOverlayRenderer::new(
        &device, 1280, 720, wgpu::TextureFormat::Rgba8Unorm,
    );

    // Game loop
    loop {
        let events = collect_platform_events(&window);
        let actions = ctx.update(&events);

        for action in &actions {
            match action {
                AppAction::SetCursor(cursor) => { /* set cursor */ },
                AppAction::Exit => return,
                _ => {}
            }
        }

        game.render(&device, &queue);
        let ui_texture = overlay.render(&device, &queue, ctx.scene());
        composite(&device, &queue, &surface, game_texture, ui_texture);
    }
}

RinchContext

RinchContext is the main handle. Create it during initialization, call update() each frame.

Multiple contexts. Several RinchContexts can coexist on one thread — a screen HUD plus N render-to-texture panels, say. Each context tracks signal changes through its own subscription (so creating or dropping one never silences another) and keeps its own document-scoped element-bounds signals, editor registrations, and focus requests. Stores/contexts are namespaced per context (#136): a create_store inside a context’s component lands in that context’s own namespace, its effects and event handlers resolve that namespace first, and lookups fall back to the thread-global namespace for stores created outside any context (e.g. before mounting). Two contexts can therefore create the same store type without overwriting each other, and dropping a context clears its namespace. One caveat remains: each context still processes the input events you feed it — route each window’s events only to its own context.

Background threads. The documented cross-thread pattern works in embed: Signal::send() / Signal::update_send() from a worker, and run_on_main_thread() for anything else, marshal the write onto the thread that created the context and apply it at the top of the next update() — before the events of that frame, so a queued write and an event handler are absorbed by the same layout pass. Everything that rides that transport comes with it: set_timeout, rinch-http completions, rinch-ws events. Signal::set() and Signal::update() still panic off the main thread — only the _send variants marshal.

The queue is process-global, so a RinchContext you drive drains work queued against any context on the thread. That is fine: the payload writes its own signals, signals are thread-local rather than per-document, and the document it touches is still repainted by its own update().

Before issue #172 an embedded context registered the main thread but no dispatcher, and a background-thread send() panicked on the calling thread. If you pinned an older rinch and wrapped your worker writes to avoid it, you can drop the workaround.

Timed work: the caret blink. A focused rich-text Editor’s caret toggles every 530 ms, and update() ticks that clock just as the desktop runtime does after each event-loop turn (issue #331). A host that calls update() every frame needs nothing more. An event-driven host — one that renders only when input arrives — asks ctx.next_wake() after each update(): it is the instant the caret next toggles (what desktop arms its ControlFlow::WaitUntil with), or None when nothing is blinking. Schedule a wake for it, or the caret toggles only when some unrelated event happens to arrive. needs_update() answers true once that instant has passed. A blurred host window (PlatformEvent::WindowFocus(false)) stops the blink with the caret solid and arms no wake. Each context has its own blink clock (issue #1149): two contexts on one thread that each have a focused editor both blink, each on its own phase, and blurring or typing in one leaves the other’s blink alone.

RenderSurface in an embedded context. A RenderSurface works inside a RinchContext the way it does on desktop: scene() tells each surface in that context’s document its physical layout size, runs its render callback, and paints its latest frame inline at the surface’s box (issue #331). Call scene() once per frame — every call runs the render callbacks again. A frame submitted from another thread makes needs_update() true and the next update() answer RequestRedraw. Several contexts on one thread each drive only their own surfaces, and a thread with no RenderSurface at all pays nothing for this in scene(). Three things are not composited by rinch in an embedded context, because the host owns compositing there: a surface registered under a data-viewport name (create_render_surface_with_name), a <video> / VideoViewport (whose frame sink is installed only by the desktop runtime), and a GpuTextureRegistrar texture on a RenderSurface (read back only through the desktop runtime’s own device, so in an embedded context it draws nothing, and its notify_frame_ready asks for no redraw). Treat them like a GameViewport hole: render into your own target and composite it under rinch’s overlay.

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

A mouse-clicked plain button does not claim wants_keyboard() — a Tab-focused one still does (issue #548). A <button>/<a href> is focusable with no tabindex needed, so a single click on one (no Tab involved) used to make wants_keyboard() answer true until the next click landed somewhere else — a host following the pattern above then stopped seeing its own Esc/hotkey presses the moment the user clicked any button in a HUD. The fix follows the browser’s own :focus-visible split rather than “is a node focused” or “did it register anything”:

  • A mouse click on a plain button/link only ever consumes Enter/Space through rinch’s own activation path, so it does not need the host’s keyboard — wants_keyboard() is false.
  • Tab (or a programmatic NodeHandle::focus()) onto that same plain button sets its keyboard-focus ring (is_focus_visible), and wants_keyboard() is true — so Tab navigation and Enter/Space activation of ordinary controls keep reaching rinch under the pattern above, exactly as before #548’s fix.
  • A custom widget that registered its own FocusEntry::on_key (register_focus_target — arrow-key navigation, a shortcut of its own) is true whichever way it was focused: the runtime can’t know which keys it wants without seeing them.
  • An open native <select> popup is always true too — its own arrow/Enter/Escape handling needs every key (a separate, pre-existing gap folded into the same fix).

A viewport hole must stay hittable — that is what routes the mouse. wants_mouse only reports “the game wants this” when the hit-tested node has a data-viewport ancestor. A hole that is not hittable inverts the routing: the hit falls through to body behind it, the walk up from body finds no data-viewport, and wants_mouse answers true — everywhere. The game silently receives no mouse input at all, with nothing in the layout or the render looking wrong.

Since #207 GameViewport needs no defending: every data-viewport node gets pointer-events: auto; background: transparent; from the UA stylesheet, so both GameViewport { name: "main" } and GameViewport { name: "main", style: "flex: 1;" } route correctly. Because those defaults are a UA rule rather than an inline style, restyling the hole cannot strip them — worth knowing, since a component’s universal style: prop replaces the component’s inline style rather than merging with it. Being a UA declaration, it also survives pointer-events: none inherited from a HUD root, and still loses to any pointer-events you declare on the viewport yourself (which is how you deliberately hand a region back to the UI).

The inheritance trap is still real for everything you build yourself. pointer-events is inherited, so pointer-events: none on a HUD root — the natural way to let clicks fall through to the game — reaches every descendant. Your own HUD controls, and any hand-rolled hole that isn’t a data-viewport node, need an explicit opt-in:

#![allow(unused)]
fn main() {
div { style: "pointer-events: none;",          // HUD root: clicks fall through
    GameViewport { name: "main", style: "flex: 1;" }   // hittable by default
    div { style: "pointer-events: auto;", /* real HUD controls */ }
}
}

That inheritance is spec-correct, not a bug — but the overlay idiom that triggers it is the obvious one to reach for, so it is worth designing around up front. (visibility: hidden is stronger still on desktop: it makes an entire subtree unhittable, and a descendant that sets visibility: visible is painted but still not hittable there — issue #843. A browser, and so rinch-web, lets that descendant take pointer events.)

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

A hole with nothing behind it: data-viewport-ready. Cutting the hole removes the ancestors’ background — <body>’s included, since the UA sheet makes it overflow-y: auto — so on a transparent window a hole nothing fills is see-through to the desktop, not to your app (#186). A viewport whose content can be absent opts out by stamping data-viewport-ready="false" on the hole; paint then leaves the backgrounds alone and the node paints its own background, which is the natural seat for a poster or an error affordance. Set it back to "true" once there is something to show.

The attribute is an opt-out, and absence means ready: a data-viewport node that never stamps it punches unconditionally, exactly as before. GameViewport is therefore unaffected — it stamps nothing, keeps its hole from the first frame, and needs no change. VideoViewport (rinch-video) is the one built-in that opts in: it stays "false" until the decoder has handed a real frame to the compositor, and flips back to "false" on a PlaybackState::Error. A node that does carry the attribute must say exactly "true" to punch, so a mis-stamped value fails to the safe side — an opaque placeholder, never a see-through window.

Not ready also means no frame, on both backends. A surface keeps the last buffer it was given, so a video that errors mid-stream still has one; while its node says "false" that frame is not drawn — the software backend paints the node as an ordinary element, and the GPU backend hands the compositor no layer for it — and the node’s own background is what shows. Say "true" again and the frame the surface holds is back, with no new frame needed.

Since #358 software cuts no hole for a video at all (see below), so there the gate decides only whether the frame or the node’s #000 placeholder is drawn.

A hole bigger than what fills it: black bars (GPU). The complementary case (#354). The GPU compositor aspect-fits a frame inside its viewport, so a source whose aspect ratio differs from the box leaves letterbox or pillarbox bars — inside the punched hole, but covered by no layer. That was see-through to the desktop for a video playing perfectly. The compositor now pushes an opaque black layer over the whole viewport box under the frame’s own layer, so the bars come out black exactly as a browser paints them for <video>, and the hole is always fully covered.

The black is painted by the compositor, not by the viewport element. That distinction is the whole design: the compositor draws under the Vello UI, so giving the element an opaque background would hide the video entirely. GameViewport is unaffected — its surface fills its box, so the fit is exact and the backdrop is entirely overdrawn.

On the software backend the same bars are black for a different reason — see the next note.

Software paints every viewport inline. The two backends route viewport frames differently, and the split is worth knowing if you are reading the compositing code (#358, #361).

RenderSurfacevideoGameViewport
softwareinlineinlineinline
GPUinlinecompositor + black backdropcompositor

Software used to blit its compositor frames onto the finished pixel buffer, after the whole UI had been painted, clipped only by the viewport’s overflow-clipping ancestors. That write has no notion of occlusion, so every overlay above a playing video or a game — the nav drawer, Modal, DropdownMenu, Select’s popup, tooltips, a HUD, DevTools — was overwritten. GPU has no such problem: its layers go down first and the Vello UI alpha-blends on top, so an opaque drawer already covers the layer there.

So on software a frame goes through paint instead, on the same inline path a RenderSurface component has always used: the data-viewport node draws the frame object-fit: contain inside its box, at its own z-order. Overlays occlude it by ordinary paint order, with no occlusion tracking anywhere, and a new frame’s damage is the viewport’s box — not a full repaint. A paint with no new frame (a hover elsewhere over a paused game) does not damage the viewport; it is repainted only where something else’s damage reaches it — a box moving across it, an overlay above it closing — and then from the frame it already holds.

It is not slower than the blit was. submit_frame checks, on the thread that submits, whether every pixel is opaque; an opaque frame is never premultiplied, and one that lands on whole pixels — at its natural size, or scaled to a box whose edges are whole pixels — is copied into the surface row by row, byte-identical to the sampled draw. Measured in release on one host (game_viewport_inline_bench), per frame on the UI thread, inline vs the old blit: a 1920×1080 frame in a 1920×1080 viewport 1.5 vs 4.8 ms, the same frame in a 960×540 viewport 2.3 vs 2.3 ms, a 320×180 frame in 640×360 0.7 vs 0.8 ms. A frame with any translucent pixel is premultiplied and sampled per draw, as any image is.

What still tells video and GameViewport apart is the hole:

  • Video punches none: it is no compositor frame, so its name is absent from the active-viewport set and #186’s filter reads “absent ⇒ do not punch”. The node fills opaque black under the frame, and the contain fit plus that fill are #354’s letterbox bars on this backend.
  • GameViewport keeps its hole while it has a frame, as it always had: its ancestors’ backgrounds are cut away under it and no black is drawn, so where the frame does not reach — a letterbox, or pixels the game left transparent — is the hole, see-through on a transparent window.

The two are told apart by an explicit flag set at video’s registration site, not by the viewport’s name — they share create_render_surface_with_name, so a naming rule would treat the game viewport as video. A surface carrying a GPU texture source never takes the inline path: it has no CPU pixels to paint, and is the GPU compositor’s.

Overlays: which ancestor sizes them. A position: absolute overlay is sized and placed against its containing block, as in a browser and under rinch-web: the padding box of its nearest positioned (or transformed) ancestor, however many unpositioned elements sit between them (#386), or the window when it has none (#204). So position: absolute; inset: 0 inside position: relative chrome fills the chrome even when the element directly enclosing it has auto height, and dropped into unpositioned containers it fills the window.

Two things to know. An overlay meant to cover one panel needs that panel to be position: relative, or it covers the next positioned ancestor up. And a position: relative inline element (a <span>) is a containing block too (#631): an overlay inside one resolves against the span’s text, not the block that holds the line. On one line that is the text’s box. For a span that wraps, the block runs from the first line’s start of the text to the last line’s end of it, so inset: 0 is usually zero wide there (the last line ends left of where the first began), as in a browser. Make the wrapper display: inline-block (or a block) when the overlay should cover a box.

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

set_scale_factor also feeds Stylo’s device_pixel_ratio (issue #211): CSS resolution media queries (@media (min-resolution: 2dppx)), image-set() candidate selection, and border-width device-pixel snapping all follow the value you pass. A changed scale restyles the document on the next update(), which also re-lays it out at the new logical size. The initial value comes from RinchContextConfig::scale_factor and is live from the first layout.

Hit testing is transform-aware, so the usual position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) centering idiom is fully clickable — a transformed subtree is hit where it is painted.

Caveat at HiDPI on the embed path. The embed path resolves layout at logical size, so set_scale_factor genuinely matters here — and paint currently applies a CSS translate without multiplying it by that scale (#202). At any scale other than 1.0, a translated element therefore paints slightly off from where it hit-tests. Only translate is affected (rotate/scale/skew are unit-invariant), and only on the embed path. Centering with flexbox instead of a translate sidesteps it entirely until that fix lands.

Platform Events

Translate your engine’s events to rinch_platform::PlatformEvent:

#![allow(unused)]
fn main() {
use rinch_platform::{PlatformEvent, MouseButton, KeyCode, KeyRepeat, 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,
    // The layout-produced `KeyboardEvent.key` value, case-accurate ("A" under
    // Shift). `None` = unknown; the app then falls back to the physical `key`.
    logical_key: Some("a".into()),
    text: Some("a".into()),
    modifiers: Modifiers::default(),
    // Is this a fresh physical press, or the OS repeating a key still held
    // (issue #463)? Say `KeyRepeat::Repeat` for a re-sent held key, and
    // `KeyRepeat::Unknown` if your engine cannot tell — the runtime then
    // infers it from the press/release pair, as it always did. Answering
    // where you can is what keeps a swallowed release from stranding the
    // Enter/Space activation of a focused node.
    repeat: KeyRepeat::Fresh,
}
PlatformEvent::KeyUp {
    key: KeyCode::KeyA,
    // Spell it like the press, or a consumer pairing press with release by
    // key string ("is A still held") never matches.
    logical_key: Some("a".into()),
    modifiers: Modifiers::default(),
}
PlatformEvent::Resized { width: 1920, height: 1080 }
}

Pointer coordinates are logical (CSS) pixels, not physical ones (issue #299). MouseMove/MouseDown/MouseUp/MouseWheel, MouseWheel’s delta_x/delta_y, and the File* positions are all in the space the document is laid out in and hit_test probes. A windowing system that reports physical pixels — winit does — must divide first, and rinch_platform::to_logical_point(point, scale) is the shared conversion:

#![allow(unused)]
fn main() {
let (lx, ly) = rinch_platform::to_logical_point((position.x, position.y), window.scale_factor());
events.push(PlatformEvent::MouseMove { x: lx as f32, y: ly as f32 });
}

Forwarding the physical position instead displaces every click, hover, drag and scroll by the scale factor times its distance from the window origin — it is invisible at scale 1.0 and wrong everywhere else. Note this is the opposite of resize(w, h) and RinchContextConfig’s width/height, which stay physical; scale_factor is how rinch reconciles the two. wants_mouse(x, y) takes the same logical pair, so ask it with exactly what you put on the event. examples/game-embed does this.

API Reference: Embed

RinchContext:

MethodDescription
new(config, component)Create and mount a rinch UI
update(&events) -> Vec<AppAction>Process events, update layout, return actions
scene() -> &SceneGet the Vello scene (lazy rebuild)
resize(w, h)Notify of window resize (physical pixels)
set_scale_factor(scale)Update DPI scale factor
set_theme(&props)Replace this context’s theme (restyles on the next update())
viewport_rect(name) -> Option<LayoutRect>Query a GameViewport’s computed rect
wants_mouse(x, y) -> boolTrue if point hits UI (not viewport hole)
wants_keyboard() -> boolTrue for a text input, the editor, an open <select> popup, a generic node that registered on_key, or a keyboard-focused (Tab/NodeHandle::focus()) generic node — not a plain button/link a mouse click merely focused (issue #548; the click-vs-Tab split follows :focus-visible)
needs_update() -> boolTrue if UI needs repaint (including a due next_wake or a new RenderSurface frame)
next_wake() -> Option<Instant>When to call update() again with no input (the caret blink), or None
register_font(data)Register font data for text rendering
app() / app_mut()Access the underlying RinchApp

RinchOverlayRenderer:

MethodDescription
new(device, w, h, format)Create from game’s wgpu device
render(device, queue, scene) -> TextureViewRender scene to texture
resize(device, w, h)Resize render target
texture()Get the underlying wgpu Texture

RinchContextConfig:

FieldTypeDescription
widthu32Initial viewport width (physical pixels)
heightu32Initial viewport height (physical pixels)
scale_factorf64Display scale factor
themeOption<ThemeProviderProps>Theme configuration

Theming is per-context. Each RinchContext owns the theme CSS generated from its config.theme (or the default theme when None) — creating a second context, or a shell app changing the thread-global theme, never restyles an existing context (issue #138). To change an embedded context’s theme at runtime, call ctx.set_theme(&props); the document restyles on its next update().

Caveat: an embedded ThemeProvider component with a reactive dark_mode_fn/primary_color_fn writes the thread-global theme slot, which embed contexts deliberately ignore — RinchContext::set_theme is the supported path for runtime theme changes in the embed API.

LayoutRect:

FieldTypeDescription
xf32Absolute X position (logical pixels)
yf32Absolute Y position (logical pixels)
widthf32Width (logical pixels)
heightf32Height (logical pixels)

Which Pattern to Use?

ScenarioUse
Adding UI overlay to an existing game engineEmbed API — game keeps its window/GPU ownership
Building a tool with embedded viewports (e.g., level editor, paint app)RenderSurface — rinch handles the window, you embed content
Terminal emulator, video player, or custom canvas inside a rinch appRenderSurface — component-level integration
WASM game with HTML-based UINeither — use the browser-native DOM backend

Examples

  • examples/game-embed/ — Spinning cube with rinch HUD overlay (embed API)
  • examples/render-surface-demo/ — Painting app with canvas and navigator (RenderSurface)
  • examples/webgpu-surface-web/ — (web) WebGPU triangle in a rinch DOM UI — the app owns a wgpu <canvas> viewport (RenderSurface on rinch-web)

Rich-text editing

Rinch ships a built-in rich-text editor — a ProseMirror-style, model-first editor with schema-enforced structure, inline marks, lists, tables, and exact undo/redo. You add it with the Editor {} component and drive it through an EditorHandle.

This page is the practical “how do I add an editor” guide. For the deeper model — the document tree, steps and transactions, the schema, commands, and the view seam — see the Rich-Text Editor guide.

Desktop and web. On desktop the editor arrives with the desktop cargo feature; in the browser rinch-web re-exports it (see On the web below) — the app code is identical, because both share the renderer-agnostic view in rinch-editor-view. There is no contenteditable HTML attribute and no DOM-level editing API to wire up — the Editor component is the whole surface.

Quick start

Create a handle with create_editor(), mount it with Editor {}, and dispatch named commands from toolbar buttons. Everything you need is in the prelude:

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

#[component]
fn my_editor() -> NodeHandle {
    let editor = create_editor();

    // One cheap handle clone per closure that needs it (EditorHandle is an Rc).
    let ed_bold = editor.clone();
    let ed_italic = editor.clone();
    let ed_h1 = editor.clone();

    rsx! {
        div {
            // Toolbar — each button runs a named command.
            div {
                button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
                button { onclick: move || { ed_italic.command("toggleItalic"); }, "Italic" }
                button { onclick: move || { ed_h1.command("setHeading1"); }, "H1" }
            }
            // The editing surface — mounts the handle and renders its content.
            Editor {
                editor: editor.clone(),
                content: "<h1>Hello</h1><p>Type here…</p>",
            }
        }
    }
}
}

The Editor component renders the document, the caret, and the selection straight from the editor’s state. It ships its own default light/dark stylesheet, so the content looks right out of the box — you don’t hand-roll editor CSS.

The Editor component

PropTypePurpose
editorOption<EditorHandle>A handle from create_editor(). Omit it and the component creates its own self-contained editor.
contentStringInitial content as schema-whitelisted HTML, parsed into the document once on mount.
read_onlyboolMount the editor read-only. false (the default) leaves the handle’s switch as it is, so a handle locked before mount stays locked. Change it afterwards with handle.set_read_only(..).

Like every rinch component, Editor {} also accepts the universal style: and class: props, applied to its host element.

#![allow(unused)]
fn main() {
// Self-contained: no handle needed if you don't drive it programmatically.
rsx! { Editor { content: "<p>Just some editable text.</p>" } }
}

The EditorHandle

create_editor() returns an EditorHandle — a cheap, cloneable (Rc) handle to a single editor. Clone one per closure that captures it; all clones share the same editor. It works before the editor is mounted or focused: state edits mutate the owned state and render when the view attaches.

Driving the editor: command

handle.command(name) dispatches a built-in command by name and returns whether it applied. This is the single entry point for toolbar buttons and menu items:

#![allow(unused)]
fn main() {
let ed = editor.clone();
button { onclick: move || { ed.command("toggleBold"); }, "Bold" }
}

Command names are case-sensitive. The full catalogue:

CategoryCommands
Inline markstoggleBold, toggleItalic, toggleUnderline, toggleStrike, toggleCode, toggleHighlight, toggleSubscript, toggleSuperscript
Block typessetParagraph, setHeading1…setHeading6, setCodeBlock
AlignmentsetTextAlignLeft, setTextAlignCenter, setTextAlignRight, setTextAlignJustify
ContainerstoggleBulletList, toggleOrderedList, toggleTaskList, wrapInBlockquote
ListssinkListItem (indent), liftListItem (outdent)
InsertsinsertHorizontalRule, insertHardBreak, insertTable
TablesaddRowAfter, addRowBefore, addColumnAfter, addColumnBefore, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell
LinksremoveLink
DeletingdeleteSelection, deleteCharBackward, deleteCharForward, deleteWordBackward, deleteWordForward
Historyundo, redo

Links need a destination, so applying a link is a builder rather than a bare named command. Use handle.toggle_link(href) to add (or, over an existing link, remove) a link, handle.command("removeLink") to clear one unconditionally, and handle.active_link_href() to read the current link’s target for an edit dialog. Text typed right after a link is not part of it (link is a non-inclusive mark), so a link does not grow as the user keeps typing past it. The one exception is a link that runs straight into a different link: text typed at that seam continues the first one (see inherited marks).

The word deletes remove from the caret to the boundary the word motion (Ctrl+Left / Ctrl+Right) lands on, taking the whitespace between: in hello wor|ld backward leaves hello |ld. A mark boundary is not a word boundary. Where there is no word to take they are Backspace / Delete: a non-empty selection is deleted as it stands, and at a textblock’s start / end they join (or lift), as Backspace and Delete do there. An inline atom — an image, a hard break — is a boundary of its own: one right beside the caret goes alone, and a word beyond one is not taken with it. There are no line delete commands. On the web the browser’s deleteSoftLine* (a soft keyboard’s line delete, Cmd+Backspace / Cmd+Delete) deletes to the edge of the visual line the caret is drawn on — on a wrapped paragraph, normally only the current line’s prefix or rest — and deleteHardLine* to the textblock’s edge (issue #301). Home / End go to the visual line’s edges on both backends. The web finds the edge from caret geometry: the first and last positions whose caret is drawn on the caret’s own line, which are the line’s logical edges — a right-to-left line starts at its right edge. Desktop reads the line from the textblock’s text layout — the line holding the caret, the upper one for a caret drawn at the end of a soft-wrapped line — and takes its logical start and end: after a hanging space at a soft wrap, before a hard break (Shift+Enter).

Caret affinity at a soft wrap. The end of one visual line and the start of the next are one model position, so a caret there could be drawn in either place. The editor keeps a hint beside the selection, as CodeMirror and a browser do: End (landing on the wrap point itself, after any hanging space), a press past a wrapped line’s end, and an Up / Down move whose column lies past the target line’s end draw it at the end of the upper line; Home and anything else draw it at the start of the lower one. So Home twice stays on its line, Home then End reaches that line’s end, End on a word broken by overflow-wrap lands after the line’s last letter (typing appends to the upper line), and a soft-line delete backward right after Home deletes one character, as Chrome does. The hint is view state, not part of the Selection: it applies only while the selection is the one it came with, so typing, an edit before the caret in its own paragraph, undo, a load or a plain set_selection returns the caret to the lower line’s start. An edit that leaves the caret’s paragraph unchanged up to the caret — in an earlier paragraph, a collaborator typing above or after the caret — carries the hint along, so the caret stays at the end of its line. A caret right before a hard break (Shift+Enter) is always drawn at the end of its own line. A paragraph that ends in a hard break shows the empty line after it, where the caret after the break sits: a browser lays <p>a<br></p> out as one line (a line after the last forced break needs something after it), and so does rinch-dom, so the view renders a second <br data-pm-trailing-break> after the break, as ProseMirror does — and after a code block’s text that ends in a newline, for the same reason. It is not part of the document and never reaches doc(), the HTML or the markdown. Set it yourself with EditorHandle::set_selection_with_affinity; read it with caret_affinity(), and caret_rect at the head draws with it.

Neither backend hit-tests points on the line (the web uses collapsed Range rects, desktop the text layout), so a caret line scrolled out of the editor’s scroller or off the page behaves as a visible one (#1026, #1107), and a hard break (Shift+Enter) always ends the line: Home after it lands after it, End before it stops before it.

Where this still differs from a browser field:

  • Desktop, Home and End beside an image: Home on a line that starts with an image lands after it, and End on a line that ends with one lands before it (#1116). The caret beside an image is drawn on its side, and a drag or Up/Down onto an image lands on the half it reaches (#1104); a hard break is resolved on both sides on both platforms (#1099, #1025), and the web resolves both sides of an image too — a click, Home, a soft-line delete, and an IME commit or autocorrect right after one.
  • On a line that mixes directions the answer is the line’s logical edge, which can differ from a browser field’s: at the end of a line whose last run is right-to-left, Chrome’s End stops before the trailing space where rinch’s lands after it, on the wrap point.

Alignment applies to the textblocks (paragraph / heading) overlapping the selection, including ones nested in lists, blockquotes, and table cells. Re-applying the alignment a block already has is a no-op, so a toolbar button bound to the current alignment stays inert. setParagraph (“reset to normal text”) clears alignment back to left along with the rest of the formatting.

Querying state

Toolbar “active” states and enablement read the editor state, never the DOM:

#![allow(unused)]
fn main() {
handle.is_mark_active("bold")          // -> bool: is bold active at the selection?
handle.current_block_type()            // -> Option<String>: e.g. Some("heading")
handle.in_node_type("bullet_list")     // -> bool: is the cursor inside a bullet list?
handle.can_run("liftListItem")         // -> bool: would this command apply right now?
}

A reactive toolbar button can read these inside a {|| ... } closure so it re-renders when the selection moves:

#![allow(unused)]
fn main() {
let ed = editor.clone();
Button {
    variant: {|| if ed.is_mark_active("bold") { "filled" } else { "subtle" }},
    onclick: move || { ed.command("toggleBold"); },
    "B"
}
}

Setting content

Pass HTML through the content: prop, or load it imperatively:

#![allow(unused)]
fn main() {
let editor = create_editor();

// Before or after mount — both work; the view renders the result either way.
editor.load_html("<h1>Loaded</h1><p>Programmatically set content.</p>");

rsx! { Editor { editor: editor.clone() } }
}
MethodPurpose
load_html(&str) -> boolParse schema-whitelisted HTML and replace the document. No markup is refused, however broken: its text is read, into valid content (see What the HTML reader keeps). Returns false only when the load itself is refused (a read-only collaborating editor).
doc() -> NodeThe current document (the save shape; serialize it under the serde feature).
insert_image(src, alt)Insert an image node (e.g. a data: URL), replacing the selection.
on_image_input(cb) / insert_image_at(&anchor, src, alt)Be offered pasted and dropped pictures and answer with the src to insert, at once or later. See Pasted and dropped pictures.
toggle_link(href) -> boolAdd a link mark with href across the selection, or remove it if the selection is already linked. No-op (returns false) for a collapsed cursor.
active_link_href() -> Option<String>The href of the link the selection is on, for pre-filling an “edit link” dialog: for a range, the first link in it; for a caret, the link text typed there would carry. A link is not inclusive, so a caret inside it answers its href and a caret at its start or right after its last character answers None — except where it runs straight into a different link, where the caret is in the first.
paste(&PasteContent) -> boolPaste text/plain and/or text/html over the selection the way the user’s paste does: your plugins first, then the default. See Seeing and rewriting a paste.
replace_selection_with_html(&str)Replace the selection with parsed HTML (the default rich paste; no plugin sees it), fitted to where it lands. See What a paste does.
selection_clipboard()The current selection serialized as (html, plain_text) for the clipboard. A selection inside a list is copied with its list, one across table cells with its table.
anchor_selection() -> SelectionAnchorCapture the selection for a later insertion, kept pointing at the same content as the user keeps editing. See Pasting is asynchronous.

HTML is schema-whitelisted on load: known block tags become nodes and known inline tags become marks; an element the schema has nothing for is read through for its content, and unknown attributes, <script>, inline event handlers and the like are dropped at parse time. The document can only ever hold structure the schema allows, and it holds all of the text: see What the HTML reader keeps.

Your own plugins and inline decorations

add_plugin(Rc<dyn Plugin>) -> bool installs a plugin of your own — a spellchecker, a search highlighter. It rebuilds the editor’s state over the current document and selection, which discards the undo history, so call it on a freshly created handle, before content is typed. It is not an edit: a read-only editor (collaborating or not) accepts it, nothing is sent to peers and on_change does not fire. A plugin whose key is already installed is refused (false), and a refused call changes nothing.

A plugin marks ranges of text through its decorations(), without touching the document:

#![allow(unused)]
fn main() {
use rinch_editor_core::decoration::{Decoration, DecorationSet};

impl Plugin for Spellcheck {
    fn key(&self) -> PluginKey { PluginKey("my-app.spellcheck") }
    fn decorations(&self, state: &EditorState) -> DecorationSet {
        DecorationSet::new(
            self.misspelled(&state.doc)
                .map(|(from, to)| {
                    Decoration::inline(from, to, Attrs::new().with("class", "pm-spell-error"))
                })
                .collect(),
        )
    }
}
}

The view wraps each decorated stretch in a <span data-pm-deco class="…">. The built-in stylesheet draws pm-spell-error (red) and pm-grammar-error (green) as wavy underlines, on desktop and on the web; any other class is yours to style. Decorations are recomputed from every new state, and the editor does not move a range for you when text is inserted before it — a plugin that caches ranges maps them through tr.mapping() in its apply.

Plugin code runs untracked (issue #943). decorations, apply, init_state, handle_paste and a plugin’s commands run synchronously inside whatever app effect called the handle (a set_selection, a command, an insert_text), and a signal they read does not become that effect’s dependency. The flip side: a plugin is not re-asked when a signal it reads changes. A spellchecker with an on/off switch re-runs its decorations by dispatching something when the switch moves, from an effect of its own:

#![allow(unused)]
fn main() {
let ed = editor.clone();
Effect::new(move || {
    let _ = spellcheck_on.get();
    // Any transaction re-asks every plugin's decorations; this one changes nothing.
    ed.update(|state| Some(state.tr()));
});
}

The build closure you pass to update is the exception: it is your own code, and what it reads tracks as usual — including what plugin code it calls reads (update(|s| { s.apply(..); … }) runs the plugins’ apply as your code).

A right press over the editor places the caret before an app’s data-oncontextmenu handler runs, on both backends, so a handler that draws its own suggestions menu reads the pressed word from selection().

Seeing and rewriting a paste

A plugin can claim a paste before the editor inserts it, through Plugin::handle_paste. It is handed the editor’s state at the paste and the clipboard’s text/plain and text/html flavours (PasteContent), and answers the transaction to apply instead, or None to leave the paste alone. Every paste the platform reports as a paste event reaches it: Ctrl+V / Cmd+V, Ctrl+Shift+V and the context menu’s Paste on desktop, and the browser’s paste event on the web, all go through EditorHandle::paste, which asks the plugins first. A mobile keyboard’s clipboard chip is the exception: it inserts text directly, as if typed, and does not reach the hook.

A URL pasted over selected words that links them instead of replacing them:

#![allow(unused)]
fn main() {
use rinch_editor_core::{
    AttrValue, Attrs, EditorState, Mark, PasteContent, Plugin, PluginKey, Transaction,
};

struct LinkOnPaste;

impl Plugin for LinkOnPaste {
    fn key(&self) -> PluginKey { PluginKey("my-app.link-on-paste") }

    fn handle_paste(&self, state: &EditorState, paste: &PasteContent) -> Option<Transaction> {
        let url = paste.text.as_deref()?.trim();
        if !url.starts_with("https://") || url.contains(char::is_whitespace) {
            return None; // not ours: the default paste
        }
        let (from, to) = (state.selection.from().0, state.selection.to().0);
        if from == to {
            return None; // nothing selected: paste the URL as text
        }
        let link = state.schema().mark_type("link")?.clone();
        let mut tr = state.tr();
        tr.add_mark(from, to, Mark::new(link, Attrs::from_iter([("href", AttrValue::from(url))])))
            .ok()?;
        Some(tr)
    }
}

editor.add_plugin(Rc::new(LinkOnPaste));
}
  • Order. Plugins are asked in the order they were installed (the built-ins first, then add_plugin’s, which claim no paste); the first Some wins and later plugins are not asked. With no claim the default runs: text/html parsed into structure, else text/plain one paragraph per line.
  • Flavours. Either may be None. A browser’s copy usually carries both, so read the text when you want to know whether the paste is a URL: a copied link’s html is an <a> whose text may be its title. Ctrl+Shift+V offers the text alone. An image-only paste (a screenshot) never reaches the hook. On desktop the text beside the html comes from the same clipboard read (paste_rich_with_text_async).
  • Where. state.selection is where the paste lands; on desktop that is where Ctrl+V was pressed, mapped through anything typed while the clipboard was read (see Pasting is asynchronous).
  • What a claim is. One transaction: one undo step, on_change if the document changed, the caret scrolled into view, and on a collaborating editor recorded and broadcast like any local edit. Some(state.tr()) swallows the paste. A read-only editor refuses the claim as it refuses every edit (and on desktop starts no clipboard read at all).
  • Borrowing. handle_paste runs while the editor is borrowed, like a command, so it must not call the EditorHandle. Anything it needs (a note’s title for a link, say) has to be at hand already.

EditorHandle::paste(&PasteContent) is the same entry point for an app of its own: a “Paste link” menu item that read the clipboard itself goes through the plugins exactly as Ctrl+V does.

Pasted and dropped pictures

By default a bitmap pasted into an editor becomes an image whose src is a data: URL, and image files dropped on a desktop editor go to the app’s own onfiledrop handler. An app that keeps pictures somewhere of its own (a blob store, an upload) asks to be offered them instead:

#![allow(unused)]
fn main() {
let editor = handle.clone();
handle.on_image_input(move |input: ImageInput| {
    // input.bytes: the encoded file (a pasted bitmap arrives as PNG)
    // input.mime:  "image/png", "image/jpeg", "image/gif" or "image/webp"
    // input.name:  the dropped file's name; None for a paste
    let src = store_blob(&input.bytes)?;        // answer at once...
    Some((src, String::new()))                  // ...with (src, alt)
});
}

The editor inserts an image with that src where the picture was aimed: the selection at the paste, or the caret at the drop point. When storing takes a round trip, keep the input, return None, and finish later:

#![allow(unused)]
fn main() {
handle.on_image_input(move |input| {
    let editor = editor.clone();
    store_blob_async(input.bytes, move |src| {
        editor.insert_image_at(&input.anchor, &src, "");
    });
    None
});
}

input.anchor is the place the picture was aimed at, kept pointed at the same content while the person, or a collaborating peer, types on. When the answer arrives the picture goes there and the person’s caret stays theirs: if they have moved on, their selection is kept (shifted by the picture when it lands before it), nothing scrolls, and the picture is an undo step of its own. If they have not moved, the caret ends up after the picture, as after any insert.

insert_image_at answers false, changing nothing, when the place is gone (the document was replaced, or a peer removed or restructured the block it was in), when the editor has become read-only, or when the place no longer takes an image. Delete what you stored for it then: nothing references it. Returning None and never inserting is a refusal: nothing is inserted, and no data: URL is made.

ImageInput and ImageInputSource are #[non_exhaustive]: read the fields you need (or destructure with ..), and give a match on the source a wildcard arm.

Pictures drawn over, and controls over a picture

The <img> of an image node asks the image loader for its node’s src. An app that shows something else for some pictures (a picture with marks drawn over it) chooses the source from the node’s attributes, once, for every editor on the thread:

#![allow(unused)]
fn main() {
rinch::editor::set_image_source(|attrs| {
    let id = attrs.get_str("data-annotation-id").filter(|i| !i.is_empty())?;
    Some(format!("{}#annotation={id}", attrs.get_str("src")?))
});
}

None keeps the src. The document is untouched (copy, export and collaboration still see the src); only the picture shown changes. The answer is what the loader is asked for and what rinch::image::reload_image names, so two images with one src and different attributes are two pictures, each loaded and reloaded on its own: reload_image("…#annotation=a1") asks again for that one alone. It is asked whenever an <img> is built or its node’s attributes change, with the editor borrowed, so it must be a function of attrs alone. clear_image_source() takes it away. In the browser it is rinch_web::set_image_source.

Installed while a component renders, the source function is removed when that component unmounts (a later install is never clobbered by an earlier unmount); installed from main, it keeps app lifetime. It is per thread, not per document: two documents on one thread (two embedded contexts) share it, the last install winning for both. Pictures already shown keep the source they were given until their <img> is rebuilt or their attributes change; handle.load_doc(handle.doc()) makes an editor’s pictures ask again.

To float controls over a picture (a button in its corner), an app hears where the pointer is:

#![allow(unused)]
fn main() {
let eye: Signal<Option<(Pos, rinch::reactive::ElementBounds)>> = Signal::new(None);
editor.on_image_hover(move |hover| eye.set(hover.map(|h| (h.pos, h.rect))));
}
MethodPurpose
on_image_hover(impl Fn(Option<&ImageHover>))The pointer came onto an image (Some), moved straight onto another (Some), or left this editor’s images (None). Called only when that answer changes; an image that was edited, or that a scroll or relayout moved, is reported again on the next move.
image_at_host(host_id) -> Option<(Pos, Attrs)>The image whose <img> is that host element (what the runtimes ask).

ImageHover carries pos (before the image: where a SetNodeAttrStep for it goes), attrs, and rect, the box the <img> is painted in, in the same frames as LinkHover::rect. The controls are the app’s own elements outside the editor, so moving onto them leaves the image and reports None; keep them shown while the pointer is over them with their own onmouseenter / onmouseleave. Not reported during a drag-select or a drag-and-drop; free while no editor has the callback.

Hover is measured on pointer moves only: a wheel scroll moves the picture under a resting pointer and reports nothing until the pointer moves, so hide the controls when the content scrolls (an onscroll on the scroller). On desktop the pointer leaving the window reports nothing either (the browser reports None). Like on_change, the callback belongs to the component that registered it: once that component unmounts it is not called again, and one registered outside any render keeps app lifetime.

With the callback registered:

  • A paste whose clipboard holds a bitmap is offered. That includes a browser’s “Copy image”, which also puts an <img> on the clipboard as html: when the html is pictures and nothing else, the bitmap is what the app gets.
  • Image files dropped on the editor are offered one by one, in order, each as soon as it is read, and the editor takes the keyboard. A file is a picture when its extension says so (png, jpg, jpeg, gif, webp), its first bytes agree, and it is at most 64 MiB (the file is read whole into memory and handed over as one Vec). The app’s onfiledrop handler gets every other file of the drop: the PDF dropped with a photo, a text file that is only named .png, a picture over the limit. A drop with no picture in it, or on a read-only editor, is the handler’s whole, as before.
  • A pasted bitmap has no size limit of its own: it is whatever the clipboard held, encoded as PNG on the UI thread (about 50 to 90 ms for a 4K screenshot).
  • Where an image cannot be inserted (the caret is in a code block), nothing is offered: the app is not asked to store a picture that would be refused.
  • An html paste that holds text as well as pictures is an ordinary paste: its <img> elements keep the src they came with. Plugin::handle_paste is where an app rewrites or strips those.

The list above is the desktop’s. The browser build offers pictures through the same callback, with these differences. There a picture is a File: a pasted image file is offered as its own bytes with the browser’s name for it, and image files dragged onto the editor from outside the page are offered at the drop point, the editor taking the drop (and the keyboard) so the browser does not open the file in place of the page. A file counts as a picture when the browser calls it image/* (so an SVG or an AVIF is offered, typed as the browser typed it), there is no size limit, and the other files of a mixed drop are ignored: the browser build does not dispatch onfiledrop.

On a read-only editor with the callback, a drop is prevented and nothing else happens: nothing is offered, the caret and the focus stay. It has to be prevented, or the browser would navigate the tab to the dropped file and the person would lose the app. With no callback a pasted image is a data: URL, as before, and a file drop is the browser’s (which opens the file; that is #1458).

Dark mode

The editor’s built-in stylesheet has light and dark color schemes; toggle between them with set_dark_mode:

#![allow(unused)]
fn main() {
let dark = Signal::new(false);
let ed = editor.clone();
button {
    onclick: move || {
        dark.update(|d| *d = !*d);
        ed.set_dark_mode(dark.get());
    },
    {move || if dark.get() { "Light mode" } else { "Dark mode" }}
}
}

Read-only

set_read_only(true) turns the editor into a document people can read, select and copy, and cannot change — what readonly is to an <input>. It is a runtime switch: flip it whenever access changes, mounted or not, and ask with is_read_only().

#![allow(unused)]
fn main() {
let editor = create_editor();
editor.load_html(&note_html);
editor.set_read_only(!can_edit);          // at open…

// …and again whenever the answer changes (a role was granted or revoked):
editor.set_read_only(!now_can_edit);

rsx! { Editor { editor: editor.clone() } }   // or: Editor { read_only: true, content: … }
}
Refused (each answers false; document, undo history and on_change untouched)Still works
Typing, IME commit, Backspace/Delete, EnterPlacing and moving the caret, pointer and keyboard selection, selectAll
Paste, cut, pasted imagesCopy (selection_clipboard, Ctrl+C, the context menu’s Copy)
Every document-changing command(..) — marks, block types, lists, indent/outdent, tables — and the keys bound to themEvery query: doc(), is_mark_active, current_block_type, …
undo / redoload_html / load_doc — the app showing a document is not the user editing one
toggle_link, insert_image, a task checkbox click, any update(..) transaction that changes the document or sets stored marksRemote collaboration updates (collab_receive), exactly as in an editable editor

It makes no difference who asks: a toolbar button calling command("toggleBold") and a keystroke go through the same gate, which sits at the one place every local change lands rather than in each input handler. can_run(name) answers false for a refused command, so a toolbar that greys its buttons from it goes inert with the switch; a toolbar of your own should also read is_read_only(). The built-in context menu offers Copy and Select all only, the OS input method stays off (no IME candidate window), and on the web the hidden capture field is readonly, so no soft keyboard is raised and the browser’s own menu drops Paste and Cut.

The caret stays visible — a reader selects and copies with it. The mounted container carries data-pm-readonly="true" while the switch is on, for your own styling; the built-in stylesheet uses it to hide the empty-editor placeholder.

Switching it on drops pending typing state (a clicked “Bold” waiting for text, an IME preedit). Switching it off gives everything back, undo history included.

Keeping the caret in view

A focused editor scrolls its caret into view after the user moves it or edits at it: typing, Enter, Backspace/Delete, every command and the keys bound to them, arrow keys and clicks, set_selection, paste, IME commit, insert_image and toggle_link. For a node selection the selected node’s outline is what is revealed, and for a cell selection the cell under its moving (head) corner.

The scroll is the minimal one (nearest): a caret already in view moves nothing, and one out of view is brought just inside the edge it left by, instantly rather than smoothly. On the web that is the browser’s scrollIntoView, which scrolls every scrollable ancestor, the page included. On desktop only the caret’s nearest scroll container scrolls (issue #842), so an editor whose scroller is itself off screen stays off screen.

It deliberately does not scroll when the caret’s position on screen changes without the user moving it — a user who scrolled away to read something is left where they are:

  • a collaborator’s edit arriving (collab_receive), even one above the caret;
  • load_html / load_doc, and add_plugin (neither is an edit);
  • a window resize that reflows the text, and the user’s own scrolling (including a long, virtualized editor measuring the blocks it scrolls past);
  • an app transaction through update(..) that edits the document and lets the selection be mapped rather than setting it. A transaction that calls tr.set_selection(..) — as tr.insert_text does — scrolls like typing; to reveal the caret after an edit of your own, set the selection explicitly, even to where it already is (let sel = tr.selection(); tr.set_selection(sel););
  • an edit a read-only editor refuses. A read-only editor’s caret moves still scroll: they are real selection changes;
  • focus on its own. A click that places the caret scrolls (it moved the selection); a click that focuses the editor without moving the caret — a task checkbox, a right-click on an image — does not jump to where the caret was, and neither does focus() when nothing is owed;
  • an editor that is not focused: its caret is not drawn, so a programmatic set_selection on it scrolls when it is next focused, not before.

Not covered: the moving end of a text range (Shift+arrow) is not revealed, and moving the caret into a block of a virtualized editor that has never been laid out (Ctrl+End from the top of a very long document) does not scroll to it until the block is on screen (issue #845). An app that wants either on screen asks for it with scroll_into_view, below.

Setting the selection from code

set_selection, command("selectAll") and any other change to the selection made by app code — from a toolbar button, a menu, a timer or an effect — draws the caret and the selection highlight without an input event in the editor. On desktop (and in an embedded RinchContext) the change wakes the runtime for one layout and overlay pass, like any other change to the page; on the web the overlays are redrawn in a microtask, once the code that made the change has returned. Only a focused editor draws a caret or a highlight, on both backends: set on an editor that is not focused, the selection is shown when it is focused.

Focus and scrolling from code

Opening a note at a link — select the words the link quotes, bring them on screen, put the keyboard there — takes three calls, and no click:

#![allow(unused)]
fn main() {
handle.set_selection(Selection::text(from, to));
handle.scroll_into_view(from, to);
handle.focus();
}

scroll_into_view(from, to) scrolls so that the range is on screen: its start, and as much of the rest as fits, 16px inside the scroller’s edge (where the document extends that far). from == to reveals a caret position. A range already in view moves nothing. It needs no focus and touches neither the selection nor focus, and it is the only way to reveal a range: set_selection brings a caret into view on its own, but never a range. The ends may be given in either order; a position between blocks reveals the nearest text.

It uses the scroll the caret uses, on two hidden boxes the view places over the range’s end and then its start, so the rules above apply: on desktop it happens after the next layout (the call wakes the runtime for one, from an effect or a timer as well as from an event handler) and moves the nearest scroll container; on the web it happens during the call and scrollIntoView moves every scrollable ancestor, the page included. A virtualized desktop editor lays out the blocks holding from and to for it. The request waits on the handle until there is geometry for its start (a block edited since the last layout has none on desktop); if the end has none by then, the start alone is revealed. A later call replaces it, local edits carry it along (text typed exactly at either edge stays outside the range), and load_html / load_doc drop it.

A request that cannot be fulfilled does not wait for good. In an editor that is not rendered (display: none, an inactive tab) the boxes are laid out at zero size on both backends, so the request is used up at once and scrolls nothing; one whose start has no geometry at all for REVEAL_PATIENCE (8) overlay passes in a row is dropped. Either way nothing scrolls when the editor is shown later: ask again then.

scroll_into_view_aligned(from, to, align) chooses where the range lands. ScrollAlign::Nearest is scroll_into_view itself. ScrollAlign::Fraction(f) puts the start’s line f of the way down the scroll container’s visible height — its padding box, inside the border, as a browser measures it (0.0 the top edge, 1.0 / 3.0 a third down; clamped to 0.0..=1.0), never nearer than 16px to either edge, and it scrolls even when the range is already in view. The scroll is clamped to what the content allows, so a range near the top of the document stays near the top of the view and one near the end sits lower. A range taller than the space below f still puts its start at f. To open a note at a link with some context above the words:

#![allow(unused)]
fn main() {
handle.set_selection(Selection::text(from, to));
handle.scroll_into_view_aligned(from, to, ScrollAlign::Fraction(1.0 / 3.0));
handle.focus();
}

It waits, carries and drops exactly as scroll_into_view does, and scrolls the same containers on desktop (the nearest scroll container, vertically). On the web it sets the nearest scroll container’s scrollTop (the page’s, when no ancestor scrolls) and then brings the start into the view of the scrollers further out, the page included, the “nearest” way. One limit: in a virtualized desktop editor the blocks the scroll brings into the window were laid out at their estimated heights when the scroll was computed and settle to their measured ones after it, so the start can land a fraction of a line off f. Both are built on NodeHandle::scroll_to_fraction(fraction, margin) (DomDocument::request_scroll_to_fraction), which any element can use.

focus() gives the editor the keyboard as a press in it would — the previous owner loses it the same way (an <input> commits its change, another editor hides its caret) — without moving the selection. It never asks for a scroll of its own, so giving the keyboard back after a dialog closes does not jump to a caret the user scrolled away from. It does perform a caret scroll already owed: a set_selection of a caret (or an edit through update that sets the selection) on an editor that is not focused asks for its caret to be shown, and only a focused editor draws a caret, so that scroll waits and happens when focus() lands. That is what makes set_selection(Selection::cursor(pos)); focus() show the caret. A range selection owes nothing (a range is never revealed on its own), which is why the deep-link example above asks with scroll_into_view. The caret or selection highlight is drawn where the selection already is. On desktop it posts the same focus request NodeHandle::focus posts (which now focuses an editor container too), and the runtime applies it through the focus arbiter after the current event or effect; on the web it focuses the editor’s hidden capture textarea during the call (preventScroll), as a mousedown does.

blur() is the other half: if this editor holds the keyboard, it lets go and nothing holds it — what a press on page content that takes no focus does. Keys stop reaching it, and its caret and selection highlight are hidden as on any blur; the selection is kept, so a later focus() shows it again. If the editor does not hold the keyboard, nothing happens: another editor or a field that has it keeps it. An app with several editors calls it when a pane stops holding a document:

#![allow(unused)]
fn main() {
// The person focused an empty pane: no editor takes keys now.
for pane_editor in &editors {
    pane_editor.blur();
}
}

On desktop it posts a release that the runtime applies at the same point as focus()’s request, and the two are applied in the order they were called: focus(); blur() in one handler or effect leaves nothing focused and blur(); focus() leaves the editor focused. A release never displaces a focus request posted by anything else (an overlay opening still moves focus into itself), and several releases in one turn are all applied. Both are applied right after a click or Enter/Space handler, otherwise before the next key press, pointer press or IME event is routed — a key already queued behind the Escape whose on_key called blur() does not reach the editor — and at the latest on the next turn of the event loop. On the web it blurs the hidden capture textarea during the call if this editor is the focused one; focus goes to the page body, as a browser’s own blur() does.

Effects queued by a signal write earlier in the same handler run before the blur, as they do before any NodeHandle call, so open.set(false); editor.blur() leaves nothing focused even when the closing overlay hands focus back to the editor. A composition in progress ends with the blur and leaves no preedit behind: the web commits it (the browser’s compositionend reaches the editor before it lets go), desktop drops it.

All three are no-ops before the editor is mounted, and nothing is remembered for the mount: call them after the Editor {} has rendered.

The editor never follows a link by itself: what a link means — a URL to open in a browser, another document in your app — is the app’s business. Two callbacks tell the app what the pointer does with links:

#![allow(unused)]
fn main() {
let ed = editor.clone();
editor.on_link_click(move |click| {
    if !click.primary {
        return false; // a plain click puts the caret in the link, as always
    }
    open_link(&click.link.href);
    true // claimed: the caret does not move
});

let tooltip: Signal<Option<(String, rinch::reactive::ElementBounds)>> = Signal::new(None);
editor.on_link_hover(move |hover| {
    tooltip.set(hover.map(|h| (h.link.href.clone(), h.rect)));
});
}
MethodPurpose
on_link_click(impl Fn(&LinkClick) -> bool)A single primary-button press on a linked character, before the caret is placed. Return true to claim it: no caret move, no drag-select, no selection change (the editor still takes focus). false and the press is an ordinary one. Called in a read-only editor too.
on_link_hover(impl Fn(Option<&LinkHover>))The pointer came onto a link (Some), moved straight onto a different link (Some), or left this editor’s links (None). Called only when that answer changes, never once per move.
link_at(pos) -> Option<LinkSpan>The link carrying the character that starts at pos, as the whole run.

A LinkSpan is the whole link as the reader sees it: href, title (None when empty) and its from..to range, taken across other formatting, so a link with a bold word in the middle is one span. LinkClick carries the span and the modifiers (ctrl, meta, shift, alt) plus primary, the platform accelerator — Cmd on macOS, Ctrl elsewhere — which is the usual “open the link” chord. LinkHover carries the span and rect, the box around the link’s painted run (a wrapped link is the union of its lines), measured when the pointer came onto it: logical window pixels on desktop, the frame bounds_signal() and root-level absolutely positioned popups use; viewport client pixels (getBoundingClientRect) in the browser.

Both are decided by the character under the pointer, not by the nearest caret position. The two differ at a link’s edges: a caret at either edge of a link is not in it (a link is non-inclusive, so active_link_href() answers None there and typing there is plain), but the pointer over the right half of the link’s last letter, or over its first letter, is on it — and the pointer over the space after the link is not.

Not reported: a double or triple press (they select a word or a block), the secondary button (the context menu keeps its own link handling), a press on an image inside a link (it selects the image), and hover during a drag-select or a drag-and-drop. A callback runs with no internal borrow held, so it may re-enter the handle — load another document, read the selection. It also runs untracked: a signal it reads never becomes a dependency of an effect that happened to call dispatch_link_click (issue #931). Like on_change, each belongs to the component rendering when it was registered: once that component unmounts the callback is not called any more (a press is not claimed, and the editor counts as having no click callback), and one registered outside any render keeps app lifetime. A pointer move pays nothing for hover while no editor on the thread has an on_link_hover callback; with one it reuses the move’s own hit test on desktop.

The callbacks are not run inside a batch() (like on_change): a callback that writes several signals flushes effects after each write. Wrap its body in batch(|| …) if that matters.

In the browser an editor link is a real <a href>, and the click that follows a press would navigate even though the editor cancels the press. Whether the browser follows it is decided per editor:

EditorA click, middle click or Ctrl/Cmd+click on a link
EditableNever navigates. A click in an editable editor is an edit gesture (it placed the caret), so following the link is always wrong.
Read-only, with an on_link_click callbackNever navigates. The callback is where the app says what a link does, even when it declines the press.
Read-only, no on_link_click callbackThe browser’s own behaviour: a click follows the link, and a middle click or Ctrl/Cmd+click opens it in a new tab.

The same rule covers an <a href> inside the editor that is not a link mark, but such an anchor is never offered to on_link_click. The right-click menu is never touched: it is the browser’s own link menu, whose “Open link” still works (see On the web).

Keyboard activation. The editor container is not contenteditable, so an editor link is an ordinary Tab stop, and the browser turns Enter on a focused link into a click with no press before it. That click is offered to on_link_click with the whole link and the modifiers held; claiming it prevents the navigation. With no claim, the table above decides.

Migration note (web). Before link events, a click on a link in an editable web editor followed the link as it placed the caret, which was a bug; it no longer does. A read-only editor used as a document viewer keeps native link behaviour as long as it registers no on_link_click. Registering one makes the app responsible for every link in that editor, middle click and Ctrl/Cmd+click included. To open links in an editable editor, register on_link_click and open click.link.href yourself (for example on click.primary, the Ctrl/Cmd+click convention). Desktop never navigates: there is no browser to follow the link.

Autocomplete popups: keys, selection and caret geometry

Four handle methods are what an app needs to run a popup off the editor, like a [[ link picker or an @ mention list. They are the same on desktop and on the web.

MethodWhat it does
on_key(|key: &EditorKey| -> bool)Offered every key press before the editor moves the caret, runs a binding or types. true consumes it: the editor does nothing (on the web the keydown is preventDefaulted and stopped). false leaves it to the editor.
on_selection_change(|sel: &Selection|)Called after every change that leaves the selection different: typing, deleting, clicks, arrows, commands, app transactions, a load that resets it, a peer’s edit that maps it. Not called when the selection is unchanged.
on_caret_moved(||)Called once the runtime has placed the focused editor’s caret somewhere new, after layout. The moment to position the popup.
caret_rect(pos) -> Option<ElementBounds>Where a caret at pos is on screen (zero width), whether or not the caret is there: logical window pixels on desktop, client pixels on the web. The frame of a position: fixed popup.

EditorKey speaks the browser’s KeyboardEvent: key is "ArrowDown", "Enter", "Escape", "Tab" for named keys and the typed text for printable ones ("[", "A" with Shift, " " for the space bar); primary is Cmd on macOS and Ctrl elsewhere, beside ctrl, meta, shift, alt and repeat.

#![allow(unused)]
fn main() {
use rinch_editor_core::Pos;

let picker: Signal<Option<Pos>> = Signal::new(None);  // where `[[` began
let popup_at: Signal<(f32, f32)> = Signal::new((0.0, 0.0));

// Open and close from the text before the caret.
editor.on_selection_change({
    let editor = editor.clone();
    move |sel| picker.set(link_trigger_before(&editor.doc(), sel.head()))
});
// Position once the text is laid out.
editor.on_caret_moved({
    let editor = editor.clone();
    move || {
        if let Some(anchor) = picker.get()
            && let Some(r) = editor.caret_rect(anchor)
        {
            popup_at.set((r.x, r.y + r.height));
        }
    }
});
// Drive the list while it is open; every other key stays the editor's.
editor.on_key(move |key| {
    if picker.get().is_none() {
        return false;
    }
    match key.key {
        "ArrowDown" => list.next(),
        "ArrowUp" => list.previous(),
        "Enter" | "Tab" => list.accept(),   // e.g. replace `[[query` with a link via `update`
        "Escape" => picker.set(None),
        _ => return false,                  // typing narrows the list
    }
    true
});
}

Every callback runs with no internal borrow held, so each may call back into the handle: read the document, run update(..) or toggle_link(..), move the selection. A selection move made from on_selection_change calls it again, from inside itself. Every callback also runs untracked (issue #931): an app effect that calls set_selection, runs a command or offers a key calls these synchronously, and the popup’s own signals a callback reads do not become that effect’s dependencies. Each is one callback per handle (a second registration replaces the first), like on_change, and costs nothing while none is registered.

Why on_caret_moved and not on_selection_change for geometry. On the web the browser lays out on demand, so caret_rect is always current. The desktop runtime lays out once per input event, after the editor has handled it, so from on_selection_change or on_key a position in the block just edited answers None (it has no layout yet). on_caret_moved comes from the caret pass after the layout. The desktop may run a caret pass before an event’s layout as well, so a call can come with None for the edited block; the one after the layout follows in the same event. It is not called for a scroll: the caret moves with the editor then, and a position: fixed popup does not.

What on_key is not offered. A key while an IME composition is in progress (the input method owns it; the composed text arrives as a commit, which is typing); on the web, a soft keyboard’s input, which arrives as beforeinput rather than keys ("Unidentified" and "Process" keys are not offered); key releases. A modifier pressed alone is offered ("Shift", "Control"); ignore it.

With an input method on (on Linux, IBus or Wayland text-input, which the editor switches on while it has focus), plain printable characters can arrive as input-method commits rather than as keys, so on_key may never see the [ of a [[ trigger. Named keys (arrows, Enter, Tab, Escape) still arrive as keys. Detect a typed trigger from on_selection_change and the document around the caret, which sees the result however the text came in.

What runs before on_key. Only a menu shortcut (a chord registered by an app menu or the DOM menu bar). on_key comes before the document-wide set_keyboard_interceptor and before the dismiss stack — Modal, Drawer and Popover’s close_on_escape, the DOM menu bar, a <select> popup — on desktop and in the browser alike. So a popup that claims Escape closes itself and leaves a Modal around the editor open; an Escape it does not claim goes on and closes the modal.

Callbacks follow their component. on_key, on_selection_change, on_caret_moved and on_change remember the component that was rendering when they were registered, and stop being called once it unmounts, even if the EditorHandle lives on (an app-level handle handed to a view that comes and goes). A live callback runs inside that component, so a signal it creates belongs to it. A callback registered outside any render (from main, a timer) keeps app lifetime.

Keyboard shortcuts

The editor handles its own keyboard input. Every shortcut below comes from the editor-core keymap (BaseCommandsPlugin/HistoryPlugin), which both the desktop and web views consult through one path — add a binding there and it works on every platform. Mod = Ctrl on Windows/Linux, Cmd on macOS.

ShortcutAction
Mod+B / Mod+I / Mod+UToggle bold / italic / underline
Mod+EToggle inline code
Mod+Shift+SToggle strikethrough
Mod+ASelect all
Mod+Alt+1…6Heading 1–6
Mod+Shift+0Paragraph
Mod+Shift+7 / 8 / 9Task / bullet / ordered list
Mod+Shift+BBlockquote
Mod+Shift+L / E / R / JAlign left / center / right / justify
Mod+Z / Mod+Shift+Z / Mod+YUndo / redo
EnterSplit block / new list item
Shift+EnterInsert a hard break (line break within the block)
Tab / Shift+TabMove between table cells, else indent / outdent a list item
Backspace / DeleteDelete backward / forward
Ctrl+Backspace / Ctrl+Delete (Alt on macOS)Delete the word backward / forward

The four alignment chords are the Google Docs ones, and some of them are also chords of the browser or the platform, which may take the key before the editor sees it. Chrome reserves Ctrl+Shift+J (and Ctrl+Shift+I) for its developer tools; Ctrl+Shift+R is the browser’s hard reload; some Linux input methods (IBus’s emoji picker) bind Ctrl+Shift+E. The commands themselves (setTextAlignLeft / Center / Right / Justify) always work from a toolbar.

The word-delete chord is the one binding whose modifier differs by platform: on macOS Cmd+Backspace is the line delete, so the word deletes are Alt(Option)+Backspace / Alt+Delete there. The desktop binding is chosen at compile time. On the web the keymap binds no word-delete chord: the browser already turns the platform’s chord into a beforeinput deleteWordBackward / deleteWordForward, which the web view runs as the commands of the same name, and leaves Cmd+Backspace to its own line delete.

(Copy/cut/paste — Mod+C/X/V, and Mod+Shift+V for paste-as-plain — are handled by the platform clipboard, not the keymap.) On desktop the same four operations — cut, copy, paste and select all — are also on the built-in right-click menu of the editor (issue #813), and the menu items run exactly the code the chords run; see Right-clicking a text field. On the web the browser’s own menu covers the editor surface. Undo/redo is a single, exact history: each undo reverses one logical edit (typing is merged into a group), because every edit is an invertible step.

What a paste does

A paste is the clipboard’s text/html parsed against the schema (unknown tags and attributes dropped), else its text/plain, one paragraph per line. The content is then fitted to the caret, as ProseMirror and TipTap do it:

  • Text and inline content go in at the caret. Several paragraphs: the first continues the line, the last takes the text after the caret.
  • A list pasted on an empty line is that list — bullet, ordered (with its start) or task (with its checkboxes). Pasted inside a line of text, its first item’s text continues the line, the other items are a list below it, and the text after the caret joins the last item. Pasted in a list item, its items become sibling items of the list the caret is in; on an empty item they take its place. That is between bullet and ordered lists, whose items are the same node (the target list keeps its kind). A task list and a plain list do not mix: one pasted in an item of the other is a list nested in that item.
  • A heading, a quote or a code block pasted on an empty line is that block; inside text a heading is its text, and a code block is a paragraph for each of its lines (the first continues the line; one copied line of code is just text in the line), never one paragraph holding line ends — wherever the caret is in a line that has content, its start included. A line selected whole counts as empty: the code block takes its place. A selection that runs into another line does not: the code is a line each, and what is left of the last selected line joins the last.
  • A rule or a table splits the line: the text before the caret, the block, the text after. At the start or end of a line nothing is split and no empty line is left.
  • In a table cell all of this happens inside the cell.
  • Over selected table cells the selected cells are emptied and the content goes into the top-left one, as typing over them does. The table’s rows and columns are never changed by a paste.
  • In a code block the paste is the clipboard’s plain text, exactly, line breaks included.
  • Over a selection the selected content is removed first; a selection of a whole line counts as an empty line.

Copying from inside a list copies the list around the selected items, so the same kind of list arrives wherever it is pasted, in rinch or elsewhere. A selection inside one line copies as its text and marks only.

Lists copied from a browser, GitHub or Google Docs paste as lists, nested ones included: Docs writes a nested list beside the item it belongs to (<ul><li>a</li><ul><li>b</li></ul></ul>), and it is read as that item’s. Nothing in a list is dropped for not being an <li>. A link or other mark around whole blocks (a card that is one <a> around a heading and a paragraph) is kept on the text of those blocks. A <b style="font-weight:normal">, which Docs wraps its copies in, is not bold. All of this is the HTML reader, so load_html and content: read the same way. (Before #1382 HTML that began with a <meta> tag, as what Chrome and Firefox copy does, was read as empty, and the paste fell back to its plain text.)

A paste the document has no valid place for falls back to the plain text; if that has none either, nothing happens. One paste is one undo step.

What the HTML reader keeps

load_html, content: and a paste read HTML the same way, and the reader is total about text: no markup is refused, the text of every element is in the document — the elements listed under “Hidden text” below aside — and the result is always valid content. Structure it cannot keep is approximated; text is not dropped. It is not a browser: it reads no CSS, so text hidden by display: none, hidden or a closed <details> is read, and whitespace is kept as written.

  • Lines are where a browser puts them. A block-level element ends the line before it even when it is empty (a<div></div>b is two paragraphs), and is a line itself when it holds something: a <div> holding only spaces is none, one that holds a non-breaking space is a blank line (and so, unlike in a browser, is one whose space is inside an inline element: <div><span> </span></div>). An inline element that holds a block ends no line by itself: x<b>y<div>z</div>v</b>w is xy, z, vw.

  • An element the reader does not know — Word’s <o:p> and <st1:place>, a custom element, <button>, <label> — is read through. It is inline unless it holds a block, so a <my-chip>b</my-chip> c is one line and <my-card><h2>…</h2><p>…</p></my-card> is its heading and paragraph. (Before #1397 the reader stopped at the first <o:p>, and a paste from Word kept its first paragraph only.)

  • Hidden text is not read: comments, conditional comments (<!--[if gte mso 9]>…<![endif]-->), <style>, <script>, <head>, <title>, <template>. Not read either, though a browser may show something for them: an <input>’s value, the options of a <select>, the fallback content of <object> / <video> / <canvas>, and <svg> and <math> (on a clipboard those are icons and the hidden copy of a rendered formula). An <svg> or <math> that is never closed ends at the first HTML element after it (<p>, <div>, <span>, …), as in a browser, so it does not take the rest of the paste with it; an unclosed <select>, <object> or <video> does, where Chrome shows what follows. An <img> with an empty src is no image.

  • Broken markup is read as a browser reads it: a <p> ends at the next block, an <li> at the next <li>, a cell at the next cell; an end tag with nothing to close is skipped, except a </p>, which ends the line as it does in a browser (<p>a<ul>…</ul>c</p>d reads c and d on two lines); a < that starts no tag (a < b) is text.

  • A bold, italic, link or code end tag written across a block (<b>a<div>b</b>c</div>d) ends the formatting where it is written, and what the block already held keeps it: a and b are bold, c and d are not (#1410; the bold used to run to the end of the document). As in a browser, such an end tag is ignored when it would reach out of a table cell or across more than seven open blocks. One browser rule is not followed: formatting that a block’s end cut short is not carried on after it, so <p><b>a</p>b has a bold a and a plain b where a browser bolds both (#1445).

  • Character references are decoded as a browser decodes them (#1415): numeric ones (&#233;, Word’s &#146;), every name of the HTML standard (&eacute;, &plus;, &check;, &AMP;), and the older names with no ; (&copy 2024 is © 2024, &notit; is ¬it;). In a link’s address a name with no ; is left alone before =, a letter or a digit, so ?a=1&copy=2 stays a query string. Anything else after an & is text (R&D, &nosuch;).

  • Table parts with no <table> are a table: a run of <td> is one row, a run of <tr> the rows of one table (#1392; they used to load as an invalid document and paste as plain text). What has no place in a table — text between its rows, a <caption> — becomes paragraphs in front of the table. That is where a browser shows it for a written <table>; for parts with no <table> a browser has no table to put it in front of, and the order can differ from both the source and the browser (<td>w1<tbody>w2 reads w2, then w1). No text is lost. Parts that do not make full rows (<td>A</td><td>B</td><tr><td>C</td></tr>) are padded with empty cells to a rectangle, as a browser renders them — up to eight empty cells for each cell written (sixteen at least). Parts whose spans ask for more (<td colspan=1000> over a few short rows) are left as written, short rows and all. A <table> is always read as it is written, short rows included.

  • A heading around blocks (<h2><div>…</div><ul>…</ul></h2>) keeps the blocks; the text between them is headings.

  • A mark around blocks marks their text and images, not their line breaks, as it does around a line (#1401). Of two links or two colours, one inside the other, the text has the inner one.

  • Nesting is kept 192 elements deep (a list level costs two or three, a quote one). Deeper elements are flattened into the 192nd: every block stays a line of its own with its text, in order; what is lost is the nesting, the marks of inline elements that deep, and the attributes of a block’s later parts when it is split around the blocks inside it (the first part keeps them). Nothing overflows, and reading stays linear in the size of the input. A document nested deeper than that does not read back as itself from its own HTML — the same lines, less nesting.

What the applications people paste from write, and how it reads:

FromReads as
WordHeadings, paragraphs, tables, links, bold. A Word list is paragraphs that start with their bullet or number (·, 1.): that is how Word writes it, and it is kept that way rather than rebuilt into a list.
Excel, Google SheetsA table (Sheets’ <google-sheets-html-origin> wrapper and Excel’s <col> are read through); one Sheets cell is a line of text.
Google DocsHeadings, paragraphs, links, nested lists. Bold, italic and underline are not read: Docs says them with CSS on a <span> (#1407). Text keeps the colour Docs gives it.
Notion, GitHub, Slack, Apple Notes and Mail, GmailTheir headings, lists, quotes, code blocks, tables and links; a <div> per line is a paragraph per line.
VS CodeA code block: VS Code copies a <div style="white-space: pre"> holding one <div> per line, and a code block is what keeps the lines and their indentation. The token colours are not kept. Pasted inside a line of text, one copied line is its text, and several lines are a line of text each: the first continues the line, the text after the caret joins the last.

Whitespace is kept as written, not collapsed as a browser collapses it, so a source that wraps its lines inside a paragraph (Word does) reads with those line ends in the text (#1406).

The rules are Transaction::replace_selection’s; see Fitting a slice.

Pasting is asynchronous

Reading the clipboard is a request to another application, and that application can be slow or hung — on X11 the read waits up to four seconds. Ctrl+V therefore does not block: the key is consumed immediately, the clipboard is read on a worker thread, and the content is inserted when it arrives (issue #149). The editor stays live throughout, which means the user can keep typing while a slow paste is in flight.

Where does the content land, then? At the position the paste was asked for, carried across whatever was typed in the meantime. Ctrl+V captures a SelectionAnchor, every intervening transaction maps that anchor forward through its steps, and the insertion happens there. Type ahead of it and the paste still splits the text where you originally pointed; type after it and the paste is unaffected; move the caret about and nothing happens to it at all — a selection-only change is not a document change.

If the document is replaced while the read is in flight (load_doc / load_html) the anchor reports None and the paste is dropped: the content it was aimed at no longer exists, and reusing the raw offset would drop it into unrelated text.

A collaborating peer’s edit is not a replacement. The anchor is carried across it as across a local edit: text a peer types elsewhere shifts it, and text a peer types in the anchor’s own paragraph leaves it at the same place in the words. It reports None only when the peer removed, split, joined or changed the kind of the block it sat in (a paragraph deleted, Enter pressed inside it, a paragraph made a list item): there the place can no longer be named.

The same anchor is available to your own asynchronous insertions — an uploaded image, a completion from a model:

#![allow(unused)]
fn main() {
let anchor = editor.anchor_selection();
let editor = editor.clone();
fetch_something(move |content| {
    // ... back on the UI thread ...
    if let Some(sel) = anchor.selection() {
        editor.set_selection(sel);
        editor.replace_selection_with_text(&content);
    }
});
}

Markdown shortcuts

As you type, the editor rewrites markdown shortcuts in place (the default MarkdownInputRulesPlugin, on by default). Block shortcuts fire on a space at the start of a line; inline mark shortcuts fire when you type the closing delimiter:

TypeBecomes
# … ###### Heading 1–6
```Code block
--- / ***Horizontal rule (fires on the third character, no space)
> Blockquote
- / * / + Bullet list
1. Ordered list
[ ] / [x] Task list (unchecked / checked)
**bold** / __bold__bold
*italic* / _italic_italic
~~strike~~strike
==highlight==highlighted
`code`inline code

The horizontal rule fires only when the marker is the paragraph’s whole content: --- typed in front of existing text, or in a heading, stays text. The caret lands in the block after the rule, or in a fresh empty paragraph appended after it (inside the same blockquote or list item) when nothing follows.

Inside a task list, Enter adds a new (unchecked) item, and Enter on an empty item exits the list — just like bullet/ordered lists. Click a task’s checkbox to toggle it done (works on desktop and web). To add your own shortcut, append a mark_input_rule / wrapping_input_rule / textblock_type_input_rule to markdown_input_rules() (or contribute an input_rules() set from your own plugin).

A complete toolbar

A full example pairing a command toolbar with the editor lives at examples/markdown-editor/src/main.rs, and examples/ui-zoo/src/sections/editor.rs shows the same pattern inside the component showcase. The shape is always: create_editor() once, clone the handle into each button’s onclick, and place a single Editor {} for the surface.

Put data-nofocus on the toolbar. A focusable toolbar button takes the keyboard away from the editor when it is pressed, so Bold would apply to a selection that no longer has focus. data-nofocus on the toolbar container makes every control inside it take its click without taking the keyboard — the preventDefault()-on-mousedown answer browsers converged on, and it works on both backends. See Taking the click without the keyboard.

#![allow(unused)]
fn main() {
div { data-nofocus: "", class: "toolbar",
    button { tabindex: "0", onclick: move || ed.command("toggleBold"), "B" }
}
}

On the web (rinch-web)

The editor runs in the browser too — the same Editor {} / EditorHandle / create_editor(), with identical app code. The renderer-agnostic view lives in rinch-editor-view and projects onto rinch-web’s web_sys DOM (the model is the single source of truth; the container is deliberately not contenteditable). rinch-web re-exports the editor, so a web app just imports it:

#![allow(unused)]
fn main() {
use rinch_web::{Editor, create_editor};

#[component]
fn app() -> NodeHandle {
    let editor = create_editor();
    let ed_bold = editor.clone();
    rsx! {
        div {
            button { onclick: move || { ed_bold.command("toggleBold"); }, "Bold" }
            Editor { editor: editor.clone(), content: "<p>Edit me in the browser.</p>" }
        }
    }
}

#[wasm_bindgen(start)]
pub fn start() {
    rinch_web::mount(ThemeProviderProps::default(), app);
}
}

A runnable demo is examples/editor-web (built with trunk serve). The browser build links no rinch-dom/Parley/CRDT engine — the browser handles layout, text, and painting.

Supported today: typing, the full command/toolbar surface, keyboard shortcuts, caret + selection rendering (pixel-accurate overlays), click / double-click (word) / triple-click (block) / shift-click / drag selection, arrow / word / Home-End / vertical navigation, clipboard (copy / cut / paste — rich text/html, image, or plain text), and IME composition (the preedit overlay matches the composing block’s font).

Clipboard and IME ride a focused, off-screen hidden <textarea> capture target (created on first editor focus): a plain non-contenteditable <div> receives no paste/cut/compositionstart events, so the editor focuses the textarea to make the browser route those native events to it — which also makes focus browser-native so keys can’t reach the wrong control. Typed characters are still consumed by the editor’s key handler (and never reach the textarea); only IME composition flows through it. This mirrors the CodeMirror / ProseMirror hidden-input technique.

The editor owns a key only while that textarea holds focus (issue #271). Tab to a button, press a focusable control (a <button>, a link, a tabindex element, a text field), or press blank page, and the editor lets go: the next key — Enter and Space included — belongs to whatever has focus now, and the editor stops painting its caret, as a blurred editor does on desktop. Press inside the editor to give it the keyboard back. A switch to another window and back is meant to leave it in place: the textarea stays the page’s focused element, and the release waits for focus to move within the page.

A press on a non-focusable clickable — a DOM menu-bar item, a div { onclick } toolbar button — leaves the keyboard with the editor, as desktop does for such a press. A focusable one takes it, DropdownMenu’s <button> items included, as on desktop. A toolbar of real <button>s that should leave the keyboard with the editor carries data-nofocus (see the toolbar note above); the editor-web example’s does. A command run while the editor keeps the keyboard with no pointer event at all — assistive technology or element.click() on a toolbar button — still moves the caret with its edit.

Right-click gets the browser’s own editing menu (issue #814) — Paste, Cut, Copy, Select All, and whatever else the browser puts there (emoji, extensions; the hidden field has spellcheck off, so there are no spelling suggestions). The editor draws no context menu of its own on the web: a page’s paste needs navigator.clipboard.readText(), which prompts. Instead, the same hidden textarea is what the browser’s menu is built for. The editor’s surface is not editable as far as the browser is concerned, so a right-click there used to open the menu for a plain element, with no Paste; now a right-button press parks the capture textarea under the pointer — invisible, but hittable — for the instant the browser needs to fire contextmenu and hit-test the point for its menu (CodeMirror 5’s technique), then sends it back off-screen 100 ms later, whether or not a menu came, so a click on the page after that reaches the page. (A click inside the textarea’s 30 px box within those 100 ms goes to the textarea instead.) The menu’s items fire the ordinary paste / cut / copy events on the focused textarea and are answered from the editor’s model: a paste lands at the editor’s selection, Cut and Copy act on the editor’s selection even when it spans blocks, and Select All — which the browser can only apply to the textarea — is detected and becomes the editor’s selectAll, however long the menu stayed open before it was chosen.

Where the right press lands decides the rest:

  • Inside a non-empty selection — on its text, a link or an image in it — the selection is kept and the menu is the editing one, so Cut, Copy and Paste act on the selection.
  • On a link or an image outside the selection (or with only a caret) nothing is parked, so the browser’s own link or image menu opens — Open link, Copy link address, Save image, Copy image — and there is no Paste at that spot. The caret moves onto the link, and over an image the selection stays where it was: that is what a native contenteditable does in Chrome.
  • On a horizontal rule outside the selection, the rule is selected, and the menu’s Copy and Cut act on it.
  • Anywhere else outside the selection, the caret moves to the press point first.

Undo and Redo in the menu act on the editor, but Chrome enables them only when its own page-wide undo stack has a step — after an IME commit in the editor, or after typing into another field on the page — so they are usually greyed out, and Redo stays greyed after a menu Undo. Use Ctrl+Z and Ctrl+Y. (Chrome aims the menu’s Undo at whichever field owns that step; the editor takes it back while it has focus, so the other field is not undone.)

The keyboard’s menu key and Shift+F10 open the same menu at the caret: the browser sends their contextmenu to the focused element, which is the textarea, and the editor parks it with its left edge at the caret first — at a selected horizontal rule, which has no caret, it parks at the rule, and with neither a caret nor a selected node — Select All in a document that ends with a rule — at the editor’s own box. On macOS a Control-click opens the menu like a right press, and the word a Mac right-click selects under the pointer is not mistaken for Select All; neither is verified on a Mac. An element up the chain carrying a live data-oncontextmenu still wins, as it does over any other element it wraps: over the whole editor, links and images included, and for the menu key and Shift+F10, which dispatch that handler with its click context at the caret and open no browser menu. What was measured, in Chrome 153: a real right press makes Chrome’s own contextmenu target the parked textarea, unprevented, where before it targeted the paragraph (outside a selection it still targets the <a> or <img>) — Chrome builds its menu for the element its hit test finds there, so the editing menu follows from that. Firefox and Safari are unverified.

Collaboration (optional, collaboration feature)

Two editors can share one live document. Enable the collaboration feature and the editor projects every local edit onto a yrs (Yjs) CRDT, broadcasts the resulting delta, and rebuilds the model from a peer’s delta when one arrives — so concurrent edits converge. The CRDT adapter (rinch-editor-collab) is the only thing in a rinch app that links a CRDT engine; default builds link none of it.

rinch = { workspace = true, features = ["desktop", "collaboration"] }

One peer hosts (it owns the starting document); the others join from a snapshot of it. Each side supplies an outbound closure — where to send a delta a local edit produced — and feeds a peer’s delta back in with collab_receive:

#![allow(unused)]
fn main() {
// Host: project the current document onto a fresh CRDT and hand peers a snapshot.
let snapshot = host.start_collaboration_host(move |delta| transport.send(delta))?;

// Guest: adopt the host's document and start collaborating.
guest.start_collaboration_guest(&snapshot, move |delta| transport.send(delta))?;

// When a delta arrives from the network, apply it on the main thread:
guest.collab_receive(&delta_bytes);
}

On desktop, network callbacks usually arrive on a background socket/data-channel thread — use the Send-safe entry point, which marshals the delta onto the main thread for you:

#![allow(unused)]
fn main() {
use rinch::prelude::*;
post_remote_delta(editor_container_id, delta_bytes); // any thread → main
}

The transport owns relaying. outbound fires only for an editor’s own local edits — a delta that arrives through collab_receive is never re-broadcast (it’s already in the shared CRDT; echoing it back would loop). So the transport must be a full mesh (every peer’s outbound reaches every other peer directly) or a hub that fans each delta it receives out to the other peers, forwarding the raw bytes unchanged. A chain — A wired to B, B wired to C, with nothing joining A and C — silently partitions: C never sees A’s edits, and nothing errors.

If a peer might have missed deltas — offline, reconnecting, or polling over HTTP with no persistent connection at all — reconcile with a state vector + diff exchange instead of relying on delta delivery being perfect:

#![allow(unused)]
fn main() {
// The peer that might be behind sends its state vector...
let my_sv = editor.collab_state_vector().unwrap();
transport.send(my_sv);

// ...the other side answers with what it's missing, plus its own state vector...
let diff = peer.collab_sync_diff(&my_sv).unwrap();
let peer_sv = peer.collab_state_vector().unwrap();
transport.send((diff, peer_sv));

// ...and the first peer applies it (and can answer the second peer's state vector
// the same way, to reconcile in the other direction too):
editor.collab_receive(&diff);
}

collab_receive is the same entry point for a broadcast delta and a reconciliation diff — they’re the same wire format, so there’s no separate sync protocol and no per-peer state to keep on either side; a stateless server can answer each state vector it’s handed with nothing held between requests.

Never use state-vector equality as a convergence check. A state vector counts insertions only, so a deletion — or a mark removal (yrs un-formats a mark by deleting its format marker) — leaves it unchanged, and two editors can hold different documents behind equal state vectors. Always request and apply a diff instead; the diff reply always carries the full delete set, which is what actually converges the peer.

is_collaborating(), stop_collaboration(), collab_snapshot() (a fresh snapshot for a late-joining peer to start_collaboration_guest from), and collab_take_error() round out the API. The first milestone covers flat text-blocks + marks (paragraphs, headings, code blocks, bold/italic/link/…), list containers (bullet/ordered lists and list items) and block quotes, nested into each other to any depth, tables (merged cells included, anything above inside a cell), horizontal rules, and the inline atoms inside a line — images and hard breaks (Shift+Enter). Task lists, and a pasted table whose rows are ragged, are still outside it: an edit outside that scope fails loud rather than silently diverging — collab_take_error() surfaces it, and the CRDT is left untouched (the local edit is not projected). Horizontal rules, images, hard breaks, block quotes and tables all joined that scope without a new format tag, so every peer on a document must be upgraded together: an older build accepts a rule, an image, a hard break, a quote or a table from a newer peer and then cannot read it, which poisons its session (see below) in both directions for as long as that content remains in the document — for a rule, an image or a hard break it heals when the last one is deleted. A quote may never leave: one emptied by two people deleting its contents at the same time is no longer shown, but it stays in the shared document for good (deleting it could race a third person typing into it), so once that has happened an older build stays locked out of the document for its whole life. A peer joining from a snapshot that already holds one fails the join instead.

Wrapping a paragraph in a quote or a list, or lifting it out of one, replaces that paragraph in the shared document, so if someone else is typing in it at that moment their typing is lost; both editors still end up with the same document.

Tables keep a full grid however their structure is edited at once: a row and a column added by two people meet at an empty cell, two columns added at once are both there in every row, and a row deleted while someone types in it is gone with that typing. What can still be lost is typing in a row, column or cell someone else deletes or merges away, and, when two people type at once into the same empty cell that such a concurrent row-and-column insert created, one of the two. Rows, columns and paragraphs that look alike are told apart by which one was actually edited, not by their content: deleting the first of three empty rows while someone types in the last keeps their typing.

A table read from someone else that would be absurdly large for what the shared document actually holds (rows and columns cost a few bytes each to send, the empty cells between them nothing) is not built: it shows up as a single empty cell instead, and while the document holds one, the editor is frozen: it refuses every edit, as a read-only editor does (typing, commands, paste, undo and load_doc/load_html do nothing and answer false; the caret, selection and copying still work), while other people’s edits keep arriving. collab_outbound_stall() reports CollabError::OversizedTable naming the table; show it — “a collaborator added a table too large to load; delete it to keep editing”. No edit is allowed near the table — not even deleting it with the editor — because the empty cell is not the table, and a change to it (or a document reloaded from an export, which cannot tell it from an ordinary empty table) could delete the real one for everybody; and none is kept to send later, because the next change from anyone else would wipe it. Two things end it: collab_oversized_tables() lists the tables and collab_delete_oversized_table(id) deletes one from the shared document for every peer (it works while frozen); or someone else deletes or shrinks it. Nothing typed is lost: nothing was accepted. One person’s edits never make such a table; two people adding hundreds of rows and hundreds of columns at the same moment could.

A table you make yourself whose merged cells span far more rows and columns than it has cells (thousands of them) is refused before it is shared, and the session reports it as not syncing until you remove it.

An image’s attributes merge one by one: two people changing different attributes of one image at the same moment (one its alt, the other its title or an app’s data-* attribute) both keep their change, and two changing the same attribute end up with one of the two values. An image is known by its type and its src: changing its src makes it a new image, and a change someone else makes to the old one at that moment is dropped. A change to an image’s attributes is kept while someone else types beside it, presses Enter or Backspace anywhere in its line, drags it, inserts or deletes another image next to it (an identical one included), copies it elsewhere, makes the line bold or turns it into a heading; if they delete the image, it is deleted. Such a change never shows on another image; pasting a picture with the same src over it counts as the same image, so the change shows on the pasted one. Some cases lose it, and both editors still end up with the same document when they do: changing the image’s src, or pasting a picture with another src over it, at that moment (the editor cannot tell the two apart, and a change dropped is better than a change shown on the wrong picture); moving the image into or out of a table cell; cutting the image and pasting it back, or deleting it and undoing the delete (the image comes back as a new one: only a move made in one step, such as a drag, keeps it); dragging an image after copying it within the editor, until either copy is changed (the two are one image to the editor, so which one moved cannot be told); and loading a document over it. Two people moving one image at the same moment (both pressing Enter before it) end up with two copies of it.

Every person sharing a document must run a version with per-attribute image merging before any of them changes an image’s attributes or moves an image: an older version refuses such an image and stops syncing until it rejoins on a newer one.

Typing right after a link while someone else changes that link at the same moment keeps their change — a new href, removing the link, or extending it over the text after it — but the character you typed may end up inside their link rather than plain, because it sits exactly where their change starts or ends. The same is true when they link the text right after yours to something else. Nothing is lost and both editors converge; the typed character is just formatted where you did not mean it (#923). A link is never brought back over text someone unlinked, and never spreads over text nobody linked.

A runnable two-pane loopback (both editors in one window, no network) lives at examples/collab-editor-demo/src/main.rs.

Outbound stalls: an out-of-scope edit, and how it un-sticks. The local edit that was refused is still in your document — only the CRDT declined it — so from that moment your editor holds content collaboration cannot express. Outbound is stalled: that edit and every one after it stays local, while inbound keeps working normally and the shared document stays healthy.

collab_outbound_stall() reports it. Unlike collab_take_error(), which is a one-shot event you take and clear, this is the state — Some(err) for as long as the condition holds — so it is what to drive a persistent indicator from:

#![allow(unused)]
fn main() {
if let Some(err) = editor.collab_outbound_stall() {
    // e.g. "Not syncing — remove the task list to resume." The error names the
    // content: "collab does not support this content yet: node `task_list` …".
    show_banner(&err.to_string());
}
}

The cure is to remove the offending content — undo the paste, remove the task list. You do not have to re-send anything: the next edit that projects re-bases on the CRDT and broadcasts everything that accumulated during the stall, in one delta. Nothing typed while stalled is lost (issue #220).

This is not SessionPoisoned and needs no rejoin — see the next paragraph for that.

Errors: transient vs poisoned. Most collaboration errors are transient: an undecodable blob from the transport, a local edit outside the staged scope, or a rebuild still waiting on an out-of-order delta’s missing dependency — the session keeps collaborating, and collab_take_error() tells you what was refused. One class is not: inbound bytes that, once integrated, leave the shared CRDT document unprojectable with nothing pending that could cure it (e.g. bytes from a foreign, non-rinch yrs document, or a peer delta carrying content this build cannot read back). yrs has no rollback, so such a session cannot receive — and, before issue #196, it would keep broadcasting while receiving nothing, silently partitioning the peers. The session now poisons itself instead: sticky CollabError::SessionPoisoned on every affected call, in both directions (local edits stop being projected/broadcast, and receives keep failing — though they are still attempted, so an inbound update that makes the document rebuildable again clears the poison on its own). A heal re-syncs the editor to the converged shared document, discarding any local edits made during the poison window — each was already refused loudly when it happened — the same semantics as stopping and rejoining. is_collaboration_poisoned() queries the state; the recovery in practice is stop_collaboration() followed by rejoining from a healthy peer’s snapshot (collab_snapshot() → start_collaboration_guest).

A read-only collaborator. set_read_only(true) on a collaborating editor makes it a live view of a document other people are writing: collab_receive (and post_remote_delta, and a reconciliation diff) integrates and re-projects as ever — remote integration does not pass through the gate local edits pass through, so there is nothing to switch off and on around it — while no local change is recorded onto the CRDT and outbound never fires. Set it before or after joining, and flip it mid-session when a role changes. Two consequences worth knowing:

  • load_html / load_doc on a collaborating read-only editor are refused (load_html answers false): with a session attached a load is a write to the shared document. stop_collaboration() first, or join the next document with start_collaboration_guest, which adopts it without writing anything.
  • collab_sync_diff is a pure read and still answers. A read-only client that was editable earlier in the session can hold edits its server lacks; whether to send that diff is the app’s call (a server that enforces the role will refuse it).

A Pos is a number into this editor’s document now: the next edit before it, yours or a peer’s, makes it point somewhere else. To remember a place in a shared document (a link to a paragraph, a bookmark, a comment anchor), ask for a sticky index instead. It follows its character through every later edit, on every replica:

#![allow(unused)]
fn main() {
// Where the caret is, as an address that survives edits.
let anchor: Vec<u8> = editor.collab_sticky_index(editor.state().selection.head()).unwrap();

// Later, on this editor or on any peer's:
if let Some(pos) = editor.collab_resolve_sticky(&anchor) {
    editor.set_selection(Selection::cursor(pos));
}
}

What it survives and what it does not:

  • Any edit elsewhere, local or merged from a peer: it moves with its character.

  • Its own character deleted: it resolves to where that character was.

  • Its block deleted: collab_resolve_sticky answers None. Joining a block into the one before it counts (Backspace at a block’s start): the projection writes the joined text as a new insert, not a move. Likewise, splitting a block (Enter) before the position moves the tail into a new block, and an index on the tail then resolves to the split point.

  • An edit next to its paragraph, at the top level or inside a list, a quote or a table cell. The projection finds the blocks an edit left alone by node identity, and a split or a join rebuilds both nodes it touches, so the paragraph beside it is rewritten even though its text did not change (tracked in #917). In practice, when the paragraph has a sibling at its level:

    • Enter at the start of a paragraph (inserting an empty paragraph above it): every index into that paragraph now resolves into the new empty paragraph. That is a wrong position, not None.
    • Backspace that deletes an empty line above a paragraph: every index into that paragraph resolves to None.
    • Toggling a bullet list on a paragraph replaces the block with one of another kind: None.

    A paragraph that is alone at its level (the document’s only block, a list item’s only paragraph) fares better: an edit there keeps nothing of the level, which is then compared by content, so Enter at its start keeps its indexes.

collab_sticky_index answers None when not collaborating, for a position that is not inside a textblock (between two blocks), for the empty starter paragraph of a shared document that has no blocks, and while the session is stalled or poisoned.

The bytes are plain yrs. They are a yrs StickyIndex in its v1 encoding (StickyIndex::encode_v1), created on the text of the textblock holding the position, with Assoc::After (or Assoc::Before at the very end of a text; an empty text names the text itself). Nothing wraps them. So an app that keeps the shared document outside the editor (a server, an index) can resolve one with yrs alone: StickyIndex::decode_v1, then get_offset on a transaction of the document, then check that offset.branch is the text of a block still under the content root. offset.index counts UTF-16 code units, like every index of this document, but only if you built your yrs::Doc for it. The offset unit belongs to each Doc and is not carried in the bytes, and Doc::new() defaults to OffsetKind::Bytes, under which get_offset gives a number in neither unit once the text has been edited (7 where the UTF-16 answer is 4, after typing 😀ö before the position). Build the replica the way the adapter builds its own:

#![allow(unused)]
fn main() {
use yrs::{Doc, OffsetKind, Options};

let doc = Doc::with_options(Options {
    offset_kind: OffsetKind::Utf16,
    ..Default::default()
});
}

The CollabDoc::sticky_index docs say the same at the source. The adapter itself exposes the pair as CollabSession::sticky_index(doc, pos) / resolve_sticky(doc, bytes), taking the model document the session projects.

On the web

The same adapter runs in the browser — yrs compiled to wasm. Enable the collaboration feature on rinch-web:

rinch-web = { path = "...", features = ["collaboration"] }

The EditorHandle collab API is identical to desktop, with two web specifics:

  • Inbound is a direct call. Wasm is single-threaded, so a transport callback (e.g. a WebSocket onmessage) already runs on the main thread — call handle.collab_receive(&bytes) (or collab_receive_for(container_id, &bytes)) directly. There is no post_remote_delta on web (that is the desktop runtime’s off-thread marshaller).
  • No randomness shim needed. yrs carries its own fastrand/js source and builds for wasm32-unknown-unknown with no extra features — unlike Automerge, which needed the app to add uuid = { version = "1", features = ["js"] } just to make the wasm build compile. rinch-web’s collaboration feature now needs nothing else configured by the app.

A runnable two-pane web loopback is examples/collab-editor-web (built with trunk serve), the browser counterpart of collab-editor-demo.

Where to go next

  • Rich-Text Editor — the document model, schema, transactions, commands, history, and the view seam in depth.
  • examples/markdown-editor — a standalone editor app (great for MCP-driven iteration; built with the debug feature).
  • examples/collab-editor-demo — two editors sharing one CRDT, live.

Rich-Text Editor

Rinch’s rich-text editor is a ProseMirror-faithful architecture in idiomatic Rust: a pure, renderer-agnostic core (rinch-editor-core) plus an equally renderer-agnostic view (rinch-editor-view) that projects it onto whichever host tree the platform provides — rinch-dom on desktop, the browser DOM on web. This page is the conceptual guide to that core — the document model, schema, steps and transactions, state, commands, history, and the view seam.

Just adding an editor to a screen? Start with the Rich-text editing guide — create_editor(), the Editor {} component, and the command API. This page is the layer underneath it.

The one big idea

There is exactly one source of truth and exactly one way to change it:

  1. EditorState is a value — { doc, selection, stored_marks, plugin_state }. No DOM node is ever authoritative.
  2. Every edit is a Transaction of invertible Steps. state.apply(tr) returns a new EditorState; it is pure and side-effect-free.
  3. The view is a pure function of state. The view diffs the old and new document and patches the host tree; it renders the caret and selection from state.selection. The host tree is never read back for content.
  4. Input produces transactions, not DOM edits. Keys, IME, paste, and pointer selection are translated into commands/transactions.
  5. The schema is authoritative and enforced. Transactions are validated; invalid steps are rejected, never silently written. Serialization is total and attr-aware — no mark or node type can be dropped.

Because the host is derived from the model on every transaction, “the DOM and the model disagree” is structurally impossible.

The document model

The model is a persistent (structurally-shared) immutable tree. Cloning a Node is a cheap Rc bump; every edit produces a new tree that shares unchanged subtrees with the old one. That is what makes states cheap to keep in history and cheap to diff in the view.

Node, Mark, Fragment, Slice

  • Node — a value in the tree. It has a schema node type, typed attrs, a Fragment of children (empty for leaves), marks (on inline/text leaves), and optional text (for the text node). hard_break, horizontal_rule, and image are first-class nodes, not strings.
  • Mark — an inline annotation (bold, italic, link{href}, …) carried on text and inline leaves, with typed attrs.
  • Fragment — an ordered, sized, Rc-shared child list with the cut / append / replace primitives the transform engine needs.
  • Slice — { content: Fragment, open_start, open_end }. The open depths let a copied or pasted range merge into the surrounding structure; it is the unit that replace and paste operate on. replace wants the open depths to line up with the range; replace_range fits a slice that does not (see Fitting a slice).
#![allow(unused)]
fn main() {
use rinch::prelude::*;            // Node, Mark, Slice, Schema, Selection, Pos, …

let doc = editor.doc();           // the current document Node (the save shape)
assert!(doc.child_count() >= 1);
}

One position space

The editor uses ProseMirror’s single depth-aware integer position space, Pos:

  • Each node contributes 1 for its opening boundary and 1 for its closing boundary.
  • Text contributes one position per Unicode scalar value (char), not per byte — byte offsets exist only transiently at platform seams (Parley layout, IME).
  • The document root starts at 0; doc.content_size() is the last valid position.

Pos resolves to a ResolvedPos that answers parent(), node(depth), before(depth)/after(depth), index(depth), and marks() — replacing flat block/inline/offset math with one consistent model.

Schema and ContentMatch

The schema defines which nodes and marks exist and what content each node may contain. The starter-kit catalogue:

  • Nodes: doc, paragraph, heading{level}, blockquote, code_block, bullet_list, ordered_list, list_item, horizontal_rule, hard_break, text, image{src, alt, title, width, data-*}, plus the table nodes.
  • Marks: bold, italic, underline, strike, code, link{href}, highlight{color?}, text_color{color}, subscript, superscript. Every mark is inclusive except link (see inherited marks).

An image keeps an app’s own data-* attributes — any valid HTML custom data attribute name (data- and at least one character, no uppercase letter, no :, at most 64 bytes), with a string value — beside src, alt, title and width. That is what HTML’s data attributes are for: an annotation id, a reference into the app’s own store, whatever the app needs to find the picture again. The editor keeps them through edits, copy and paste (each written to HTML and to the host <img> as itself, an empty one included) and collaboration, and does nothing else with them.

  • Reserved names. A name rinch’s own code reads or writes on elements is never kept, written, stamped or read from HTML: data-pm-*, data-rinch-*, the shell’s state families data-text-sel*, data-cursor-*, data-selection-*, data-tcm-*, and the whole names in rinch_editor_core::schema::RESERVED_DATA_ATTRS — data-rid, each data-on… event name rsx! writes (exactly those: data-one or data-online are yours), data-nofocus, data-block-index, data-type, data-checked, …. is_app_data_attr(name) is the one test; a ratchet test fails when rinch’s sources start using a name that is neither reserved nor known to be safe.
  • Bounds. A node keeps at most 32 app data attributes (MAX_DATA_ATTRS), each value at most 1024 bytes (MAX_DATA_ATTR_VALUE). Past either bound an attribute is dropped whole, never truncated; the first ones are kept (in document order from HTML, in name order otherwise), and of two with one name, the first. The bound is the model’s: every node of an opted-in type is built through it (create_node, a SetNodeAttrStep, a node collaboration builds from a peer’s update, which a peer that does not bound them can send), so the document holds exactly what HTML, DocNode and the view keep. A reserved, invalid or non-string data-* on such a node is dropped the same way.
  • Paste. The default paste keeps an image’s data attributes only when rinch’s own copy-out wrote them: selection_clipboard marks each element that carries them with data-rinch-clip (serialize::CLIPBOARD_MARK, reserved, so it never reaches a document), and the paste (replace_selection_with_html, serialize::slice_from_pasted_html) keeps them only beside it. So a copy between two rinch editors keeps them with no plugin, and another application’s (Slack’s data-stringify-type, Vue’s data-v-…, React’s data-reactid, a lazy loader’s data-src) are dropped. A Plugin::handle_paste sees PasteContent::html whole and can keep what it wants. A load (load_html, slice_from_html) keeps every one, bounded. An app inserting its own markup with replace_selection_with_html keeps its data attributes by writing data-rinch-clip="" on the element that carries them (or builds the node and inserts it with update). The mark is a filter for other applications’ noise, not proof of where markup came from: a page can write it too, and what it then keeps still passes the reserved list and the bounds.

Set or remove one like any attribute:

#![allow(unused)]
fn main() {
handle.update(|state| {
    let mut tr = state.tr();
    tr.step(Box::new(SetNodeAttrStep::new(pos, "data-annotation-id", AttrValue::from("a1"))))
        .ok()?;
    Some(tr)
});
}

The opt-in is the node spec’s: NodeSpec::data_attrs (NodeSpecBuilder::data_attrs(true)). The starter kit’s image sets it and nothing else does. Only a leaf node type (an atom: <img>, <hr>, <br>) may set it — SchemaBuilder::build panics for one with content — because HTML carries the attributes on a void element only. An attribute a node’s spec neither declares nor keeps is dropped by validation (to_doc, node_from_doc), and the HTML writer does not write it.

width (whole CSS pixels, absent or not positive for the natural width; read from HTML by HTML’s dimension rules, so 320px is 320, rounded and at most 65535) is the <img>’s width hint and HTML’s width. A GFM image ![alt](src) has neither width nor data attributes, so it is written without them; an image in a table written as HTML keeps both. Like every attr of an inline atom they merge per attribute when collaborating: two peers changing different attrs of one image at once both keep their change, two changing the same one converge on one value, and a peer’s attribute follows its picture through Enter, Backspace, drags and pictures inserted beside it, and never shows on another picture (one pasted over it with the same src counts as the same picture: identity is type and src). A change of an image’s src (or a picture with another src pasted over it, which the editor cannot tell from one) makes a new picture, and a concurrent change of the old one is dropped; so does cutting and pasting a picture, undoing its delete, or dragging it after an in-editor copy of it. Chosen by Joe (2026-10-09): a wrong attribution is worse than a lost one — app data keyed to a picture would show on the wrong picture (rinch-editor-collab/tests/image_attrs.rs, atom_attr_merge.rs, atom_identity_differential.rs).

Each node spec carries a content expression (e.g. blockquote > block+, list_item > block+, bullet_list > list_item+). These compile to a ContentMatch NFA that the transform engine consults to decide whether a step’s result is valid — matchType, matchFragment, and fillBefore drive both validation and the automatic insertion of required nodes. Required attrs (link.href, image.src, heading.level) are applied/enforced at the step boundary, so attr-aware round-trip is structural, not best-effort.

Node and mark types are interned per Schema instance, and compared by pointer. NodeType/MarkType equality is Rc::ptr_eq, so two Schema::starter_kit() values mint two bold handles that are never equal — and a Mark carries its MarkType, so marks from different schemas never match either. One document, one Schema: build the document, the transform, and every mark you hand it from the same Rc<Schema>.

Getting this wrong used to be silent. Transform::remove_mark matched nothing and returned Ok, so the removal quietly did nothing; add_mark added the foreign mark beside the document’s real one. Both now fail loud with a StepError naming the cause (issue #217) — but only for a genuine type-identity mismatch. Removing a mark the range simply does not carry, or a link[href=a] where the text has link[href=b], is still an ordinary no-op. The safest way to name “whatever this document calls bold” is to read the handle off the document (or off state.schema(), which is the same instance).

The same pointer identity is why comparing Nodes across two editor handles fails even for structurally identical documents — compare their serialized HTML instead.

This used to reach EditorHandle too, and no longer does (#440). create_editor mints a new Rc<Schema> per handle, so b.load_doc(a.doc()) hands b a document built by a’s schema — but load_doc (and therefore load_html, and the collaboration guest join) now re-homes the incoming document onto the receiving schema first (Node::rebind), matching every node and mark type by name against b’s own interned handles before installing it. b.is_mark_active("bold") then answers true over the adopted bold text, and toggleBold removes the mark rather than returning false (the pre-#440 guard’s refusal) or — on an unfixed build older than #217’s guard — duplicating it. b.load_doc(a.doc()) is the documented way to move a document between handles; no manual serialization round-trip is needed for it any more. Node::rebind costs one pointer comparison per node/mark when the document is already on the target schema (the guest-join case: projected_doc already builds through the guest’s own schema), and a rebuild only for a genuinely foreign type.

Serialization

The durable save/load shape is a recursive, schema-derived structure (under the serde feature). Serialize walks the schema, so every node type and mark type has a name and there is no string-tag fallthrough; deserialize consults the schema and rejects unknown types rather than dropping them silently. HTML serialization derives tags from the schema’s parse_html_tags, so copy-out and paste-in share one table — the same one Editor’s content: prop and load_html use.

The HTML import is total about text (#1397): slice_from_html does not fail on markup, the text of every element it reads is in the slice it returns (it reads no CSS, and drops a fixed set of elements whole: scripts, styles, embedded content, <select>, <svg>, <math>), and the slice’s content is valid. It is two passes. The tree builder (serialize/html_tree.rs) follows the HTML parsing rules as far as they decide what is text and what holds what: a tag name runs to the next whitespace, / or > (so <o:p> is an element — the old tokenizer stopped reading at the colon); comments, <![if …]>, <style>, <script>, <title> and <head> give nothing; a <p>, <li>, <td> or <tr> ends where a browser ends it; an end tag closes the nearest open element of its name unless a table cell (or, for an inline element, a block) is in between; a formatting element’s end tag (</b>, </i>, </a>, …) across an open block ends the element there and leaves what the block already holds formatted (#1410: the adoption agency algorithm, done by turning the element’s stack entry into a placeholder and copying the element around what each element open above it holds, so the tree gets no deeper; skipped with more than 7 blocks or 32 elements open in between); a </p> with no <p> to close ends the line; an unclosed <svg> or <math> ends at the first HTML element. Character references are the HTML standard’s 2,231 (#1415; html_entities_table.rs is generated by tools/gen_html_entities.py from the standard’s entities.json). The tree is at most 192 elements deep (html_tree::MAX_DEPTH), so the reader’s recursion is bounded (10,000 nested <div>s overflowed the stack): an element opened deeper is still open, so its end tag is its own, but it is flattened into the element at the limit — a block as a child of it, split around the blocks inside it, an inline element as its content alone — so past the limit no text is lost and every block is still a line of its own. Chrome’s limit is 512 and it too keeps every element; 192 is what an unoptimized build reads on a 2 MB stack (about 6.4 KB a level). A tag scans the innermost 192 open elements for the one it closes and past those asks an index of where each name is open (open_at), so it closes what an unbounded scan would at a fixed cost: reading stays linear with thousands of elements open, and a block’s end tag ends it however many unclosed elements are inside. The reader (serialize/html.rs) then maps the tree onto the schema:

  • An element with no node or mark is read through. It is inline unless it holds a block or is a block-level HTML element (div, section, …).
  • A block-level element that holds no block ends the line before it (#1413) and is a line itself when the text it holds directly is more than ASCII whitespace (a non-breaking space); empty, or holding only spaces and line ends directly, it is none. (A space inside an inline element in it still makes a paragraph: <div><span> </span></div>.) An inline element that holds a block ends none: its children are read where it stands, so the inline content beside the block stays on the line of what is beside the element.
  • A run of table parts with no <table> is a table; what has no place in a table (text in a row, a <caption>) is read as blocks in front of it, as a browser’s foster parenting puts it.
  • A textblock element (a heading) that holds blocks is not flattened: its blocks stay blocks and the inline content between them takes the element’s type.
  • A <div style="white-space: pre"> is a code block, one line per <div> or <br> (what VS Code copies); so is <pre>, where a <br> is a line end too.
  • A mark element around blocks marks what the same element marks around inline content: text and images, not hard breaks (#1401). Mark elements nested directly in one another mark the blocks once for each mark type; with a block-level element between them it is once a level, as before. Neighbouring text with the same marks is one text node, and a mark of a type replaces an outer mark of that type.

tests/html_reader_total_1397.rs is the property test: a tag-soup generator that knows what a browser shows, checked for text conservation, validity and reading back the same, plus a step-count pin that reading is linear; tests/html_reader_clipboard_samples.rs holds what Word, Excel, Sheets, Docs, Notion, GitHub, VS Code, Apple Notes and Mail, Slack and Gmail write.

The HTML import reads its three integer attributes as Chrome 153 does (#1164): <ol start> by HTML’s rules for parsing integers (" 3", "3abc" and "2.5" are 3, 3 and 2; a value past i32 is the default 1), and colspan / rowspan clamped to 1..=1000 and 0..=65534, a value too large to parse being the maximum, and a negative or unparsable value 1. The model has no “to the end” span, so rowspan="0" is imported as the number of rows left in the cell’s row group (<thead>, <tbody>, <tfoot>, or a run of bare <tr>s), counting its own — rows the import keeps: a <tr> with no cells is dropped and not counted, where Chrome counts it. Any other rowspan is cut at the same place, the end of its row group, which is where Chrome 153 cuts it when it lays the table out (#1176).

A pasted row’s spans also reach at most 1000 grid columns (tables::MAX_IMPORTED_ROW_WIDTH), counting the columns that rowspans from the rows above carry into it: a colspan is cut to what is left, never below 1, and a cell that starts in a row already 1000 columns wide spans that row alone, so it carries nothing down (#1176). Each such cell adds one column, so a row is at most 1000 columns plus one per cell that found it full. That limit is rinch’s, not Chrome’s (Chrome lays out a row of 3001 columns); without it a row’s width grew with every rowspan above it, and a 57 KB paste made a 1,000,000-column table.

A loaded document keeps no colspan past 1000 either (#1214, Chrome’s limit): tables::cap_colspans cuts every larger one to tables::MAX_COLSPAN, and Schema::node_from_doc, EditorHandle::new and EditorHandle::load_doc call it, as the HTML import (load_html, paste, Editor’s content:) already reads colspan. One colspan = 3,000,000 cell made a two-row table 2^21 columns wide, and addRowAfter built a cell per column: 2,097,152 cells, 15.6 s and 5 GB through a mounted view, 3.8 ms after the cap. Edits are not capped — an app’s own transaction, a peer’s change (and the shared document a collaboration guest adopts on joining), or a command (addColumnAfter across a 1000-wide cell makes it 1001) — so node_from_doc is not a lossless inverse of to_doc for a cell that went past 1000 that way. rowspan is not capped: a grid is never taller than its rows. The row-width limit above is the HTML import’s alone: a loaded row of 2,100 colspan = 1000 cells is still 2,100,000 columns wide.

Whatever route a table takes into the model — paste, load_doc, an app’s own transaction, the table commands — the grid TableMap builds over it is bounded: tables::column_count caps width × rows at tables::grid_slot_budget, twice the table’s cells and never less than 2^22 slots (32 MB of map). A rectangular table with no spans fills exactly width × rows slots with as many cells, so it is never cut; what can be cut is a grid of more than 2^22 slots and more than twice its cells, as spans or ragged rows make one. Past the cap a cell is cut at the grid’s right edge, and one that starts past it is in no slot, so the table commands do nothing there.

A slot no cell covers — the tail of a row shorter than the grid, or what the cap cut — is a hole, and the table commands treat it as no cell (#1184): deleteRow and deleteColumn pass over it, addColumnBefore/addColumnAfter give a row with a hole at that column a new cell at its end, a row added by addRowBefore/addRowAfter gets a cell in a hole’s column, and mergeCells grows the top-left cell over the holes in its rectangle. Every span a command writes is the cell’s extent in the grid ± 1, never the attribute’s own value ± 1: a colspan of i64::MAX in a two-row table (which an app’s own transaction can still write; a load caps it at 1000), which the grid cuts to 2^21 columns, is 2^21 + 1 after addColumnAfter across it.

The view lays a table out as that same grid (#1182): <table> is a CSS grid of tables::column_count columns and each cell is placed by its rectangle in the map (tables::cell_rects), not by its raw attributes — so a rowspan of i64::MAX in a two-row table spans two grid rows. The rectangle is written as definite grid lines, grid-column: <left + 1> / <right + 1> and grid-row: <top + 1> / <bottom + 1> (#1209): CSS auto-placement knows no rows and packed a cell of a row that a rowspan leaves short into the row before it, where the map has it below. A cell the map has no slot for is laid out as a band across the whole grid (grid-column: 1 / -1) on a row of its own (the first the grid leaves empty, normally after its last), so its text stays visible and adds no column. Written raw, a few cells of huge spans stacked past the 32767 grid lines Taffy 0.12 numbered a grid with, and desktop layout panicked; Taffy 0.14 (#1236) clamps a grid axis at 10000 tracks instead and overlaps whatever lands past them. Two limits remain, both from the desktop’s Stylo clamping a grid line, a template and every span to 10000: a table wider than that is drawn 10000 columns wide; and an axis past 9999 tracks, whose lines would be clamped onto one, is placed by span <n> and auto-placement instead. A table past 9999 rows keeps its column lines, so a cell is still placed below the one before it in its column. On the desktop such a table still meets Taffy’s own clamp: a table of more than 10000 rows overlaps its rows from the 10000th on (they lay out in the last grid track), where Taffy 0.12 laid out up to 32767 lines correctly. A table past 9999 columns loses its row lines too, because cells locked to their row with auto-placed columns can grow the grid past what Taffy numbers (on Taffy 0.12 a row of four colspan = 20000 cells panicked; 0.14 clamps the axis at 10000 tracks); there a short row’s cells are still lifted into the row before it, on the web as well as the desktop.

Markdown

Under the markdown feature, serialize::doc_to_markdown(&doc) writes a document as Markdown and doc_from_markdown(&schema, md) / doc_from_markdown_strict(&schema, md) read it back. Markdown is for people and language models to read and write; the durable format is still the DocNode shape above.

The dialect is CommonMark with GFM strikethrough and pipe tables (read by pulldown-cmark), plus a small, exact set of inline HTML for what Markdown has no syntax for:

modelwritten asalso read
bold, italic, strike**…**, *…*, ~~…~~; <strong>, <em>, <s> where a delimiter would not flank<b>, <i>, <del>
underline<u>…</u>
highlight<mark>…</mark>, <mark style="background-color:C">…</mark>
text_color<span style="color:C">…</span>
subscript, superscript<sub>…</sub>, <sup>…</sup>
link, image, code, hard_breakMarkdown syntax; a hard break is <br> at a textblock’s end and in a heading<br>, <br/>
tablea GFM pipe table when it has one header row, no merged cells and one inline paragraph per cell; otherwise an HTML <table> block
task_list > task_itemGFM’s - [ ] … / - [x] …; in a table written as HTML, <ul data-type="taskList"> > <li data-type="taskItem" data-checked="true">[X]

C is a colour is_safe_css_color accepts (the HTML paste path’s check). The writer writes a colour only when it passes: a highlight with any other colour is written as a bare <mark>, and a text_color with one is not written at all, so a colour that arrived unchecked (a DocNode, a collaboration peer) never reaches the output. HTML copy-out (node_to_html) applies the same check, so it no longer writes a colour like var(--x) or oklch(…). Tag names are case-insensitive; the style attribute must be lowercase and quoted (either quote) and hold that one declaration.

Task lists (#1365). An item’s marker starts its first paragraph, and the item’s other blocks are indented under it like any list item’s. An item that starts with another block (a heading, a quote, a list, …) is written with the marker alone on its line, - [ ] with a trailing space, and the block on the next line: written after the marker, pulldown-cmark misreads a quote’s or a nested list’s later lines. A rule in that position is written ***, because --- under the marker’s line is a setext heading to GitHub. An empty item is the marker and its trailing space; GFM itself has no empty task item, and GitHub shows one as the text [ ]. A bullet or task list that directly follows another is written with * instead of - (and back), because CommonMark continues a list across blank lines when the bullet is the same, which would turn a bullet list and the task list after it into one list.

On reading, a bullet list whose items all start with a marker is a task_list. The reader finds a marker in the item’s source rather than trusting pulldown-cmark’s event for it, which arrives inside whatever paragraph comes first or, before a heading (`- [ ]

Heading), not at all. A marker with nothing after it on its line (- [ ]`, the

writer’s empty item once an editor or an LLM strips trailing whitespace) is text to pulldown-cmark; both readers take it as an empty item’s marker when it is the item’s whole first paragraph. A bullet item whose text is [ ] is written - \[ \], so it stays a bullet. A marker on an item of an ordered list, or of a list where some items have none, has no place in the model: the strict reader refuses it (Construct::TaskList) and the lenient one gives it back as text at the start of the item ([ ] todo; before another block, a paragraph of its own).

HTML copy-out (node_to_html, slice_to_html) writes a task list with the same data-type / data-checked markup (TipTap’s), and slice_from_html parses it, a run of bare <li data-type="taskItem"> (a selection inside one list) included, as a task_list. A pasted task list is fitted like any list (Fitting a slice): on an empty line or an empty item it keeps every checkbox; inside text its first item’s text continues the line (that item’s checkbox goes with the item) and the rest stays a task list. In a collaborating editor a pasted task list stalls outbound like any other (A22).

What round-trips. A document of the starter kit’s marks and nodes written with doc_to_markdown reads back with doc_from_markdown_strict as the same document, and writing it again changes nothing — except for the losses below. tests/markdown_round_trip_fuzz.rs holds the writer to that over seeded random documents. Text is escaped so it reads back as text: Markdown punctuation, a block marker at a line’s start, < (a tag or an autolink), an entity-shaped &, a trailing # run in a heading, and a line break inside text (written &#10;).

Strict reading. doc_from_markdown is lenient: what it cannot represent (other raw HTML, unsafe URLs, footnotes, a task-list marker in an ordered or mixed list) it drops or keeps as text. doc_from_markdown_strict parses the same way but fails with MarkdownError::Unsupported { construct, line, source } on the first construct the lenient read would drop or degrade, naming it with a Construct (InlineHtml, HtmlBlock, UnmatchedTag, Footnote, TaskList, UnsafeLink, UnsafeImage, UnsupportedMark, Other; the enum is #[non_exhaustive]) and its 1-based line. MarkdownError::Invalid carries a schema validation error. Strict accepts everything the writer writes (strict_reads_everything_the_writer_writes in the fuzz holds it to that, losses included): a textblock of only hard breaks, written <br> alone on a line — which CommonMark reads as an HTML block — reads back as one. Inside an HTML <table> block strict refuses another tag, stray text, an unsafe href or src (UnsafeLink / UnsafeImage), and any attribute the import does not keep — a style other than a safe colour or text-align, a colspan above 1000, a class, an event handler, a data-type other than taskList on a <ul>, a data-checked other than true/false or outside a task list (HtmlBlock). What strict accepts, it parses exactly as the lenient reader does.

Known losses.

  • Whitespace at the start or end of a textblock or line is stripped (CommonMark; not in a table written as HTML, where it round-trips), and whitespace at the edge of a bold, italic, strike or link run is written outside the run: the text round-trips and that whitespace leaves the mark.
  • A code block’s language is lost in a table written as HTML; two adjacent ordered lists, or two adjacent blockquotes, merge; empty paragraphs are dropped; a link href containing \ before punctuation or an entity is decoded on read (#1366).
  • A line break inside inline code becomes a space (a code span is literal), and one inside a link href reads back as text.
  • An ordered list numbered past nine digits is not a list to CommonMark.
  • A colspan above 1000 or a rowspan above 65534 is written clamped to those limits, which the HTML import applies anyway.
  • A paragraph’s or heading’s indent is not written, and a link’s target and a textblock’s text_align only in a table cell.

The transform engine

Every editing operation is a Transaction carrying one or more Steps. Steps are invertible (for undo) and mappable (for redo, collaboration rebase, and decoration tracking).

A deliberately minimal Step set

There is not a step per gesture. A small primitive set expresses everything; the gestures map onto it:

StepPurpose
ReplaceStep { from, to, slice }Replace a range with a Slice. The workhorse — text insert, delete, split, join, and paste all reduce to this.
ReplaceAroundStep { … gap …, slice }Replace around a preserved gap — wrapping (blockquote/list), lifting, and re-parenting block-type changes.
AddMarkStep { from, to, mark }Add a mark across an inline range.
RemoveMarkStep { from, to, mark }Remove a mark across an inline range.
SetNodeAttrStep { pos, attr, value }Change one node attr (heading level, image alt, list start).
SetDocAttrStep { attr, value }A document-level attr.
BatchStep (Transaction::batch(Vec<BatchEdit>))Many disjoint replaces and node-attr changes, stated in the coordinates of the document it applies to, as one step.

BatchStep is the one step kind that exists for cost rather than expressiveness. It means its edits applied one at a time from the last position to the first (two changes of one node’s attribute: the last given wins): it builds the document they build, and its step map maps every position as those ReplaceSteps did. It differs in three places. Each rebuilt node’s content is checked once, on the result, so it accepts a batch whose sequence would pass through an invalid intermediate state. Mapped over another change (Step::map, a rebase), each range maps as one ReplaceStep of it would — and inserts at one point, and deletions that meet, are one range from construction, so a concurrent insert between two such deletions is deleted with them where two separate deletions would keep it. Two kept ranges the change brings to meet end to end become one replace of both — the same document — unless one carries an open slice: then the later edit is dropped and its content is lost, where separate steps would apply it. An attribute change the change puts inside a kept range is dropped as well. No table command builds an open slice. And a selection a command does not set is mapped once through the step and resolved in its result, where a step per row mapped it after every step: in about 0.6% of the commands the table differential compares, the selection lands somewhere else (the old one was placed in an intermediate document). It rebuilds each node on the way to an edit once and keeps one document in the transaction rather than one per edit. Every table command is one BatchStep (#1200): as a step per row, a column into 16,000 rows rebuilt and kept the row list 16,000 times (6.4 s, 2.1 GB; 29 ms as one step). The one exception is deleteRow / deleteColumn on a table whose cells overlap, which still removes one row or column per step on a recomputed map. Collaboration adds no step kinds.

A few gesture → step mappings:

GestureStep(s)
Type a characterReplaceStep(from, to, Slice::text(ch, stored_marks))
Backspace mid-textReplaceStep(pos-1, pos, empty)
Enter / split blocka ReplaceStep that splits the textblock (open slice + new block)
Toggle bold over a rangeAddMarkStep / RemoveMarkStep over from..to
Toggle bold at a cursorno step — set stored_marks (applied to the next typed text)
Wrap in blockquotewrap(range, [(blockquote, {})]) → ReplaceAroundStep
Pasteparse to a Slice, then replace_range(from, to, slice): one ReplaceStep, or a ReplaceAroundStep when the text after the caret moves into the pasted content

Fitting a slice: replace_range

ReplaceStep is strict: the slice’s open depths must line up with the range, and every node it rebuilds must be valid. Content that comes from somewhere else rarely lines up — a list has no place inside the paragraph the caret is in — so paste goes through Transform::replace_range(from, to, slice) (Transaction::replace_range, and Transaction::replace_selection(slice), which also places the caret). It is ProseMirror’s replaceRange and Fitter (transform/fit.rs), and it adds one step or fails with nothing changed.

The rules, with | the caret and a pasted <ul><li>A</li><li>B</li></ul>:

WhereResultWhy
`

a

bc`<p>aA</p><ul><li>Bbc</li></ul>
`

` (an empty line)the list
`
  • x
y`<ul><li>xA</li><li>By</li></ul>
an empty itemthe pasted items, in its placeas the empty line
`

a

bc, pasting
` or a table
<p>a</p><hr><p>bc</p>
a table cellas above, inside the cellA fit never leaves the isolating node the range starts in
`
  • x
y`, pasting a task list<ul><li>xA<ul data-type="taskList"><li>By</li></ul></li></ul>

NodeSpec::defining (ProseMirror’s) marks the nodes that are kept on an empty line: list_item, task_item, heading, blockquote and code_block in the starter kit. A pasted <h2> on an empty line is therefore a heading (it was a paragraph with the heading’s text before #1382), and in the middle of a paragraph it is its text.

slice_from_html decides the open depths, since markup has none: each edge is open down to the textblock there, and closed when it reaches none (a rule, a table). So a list is open through its first and last items, as a selection inside it is.

What it does not do, where ProseMirror does:

  • It never creates a node to make content valid (fillBefore) and never wraps content in a node it was not in (findWrapping): the content matcher cannot be asked for either. A fit that would need one fails, and a paste then falls back to its plain text.
  • A range whose two ends are in different isolating nodes (two table cells) is not fitted: only the plain replace is tried, as before.
  • A cell selection is not a range: its from()..to() are the positions before its two corner cells. Transaction::replace_selection refuses one. The paste (EditorHandle) clears the selected cells (commands::table_ops::clear_cells) and fits the content in the top-left cell, in one transaction — what ProseMirror’s CellSelection.replace does with content that is not cells. replace_range itself takes positions and will fit whatever range it is given.
  • A slice holding an invalid node (the nodes open at its edges aside) is refused. The HTML reader never makes one: a <td> or <tr> with no table around it is read as a table (#1392).

The fitter keeps, per open node, how far its children have got through the content expression (ContentMatch::start / advance / accepts_end) and steps it once per node placed, so a paste costs in proportion to its size (a_fit_is_linear_in_what_it_places counts the steps at n and 2n).

tests/replace_range_fuzz.rs pastes random slices over random ranges of random documents: no panic, no invalid document, every step undoes exactly, and the content the HTML reader makes of the fuzz’s markup (every starter-kit block, nested) always fits at a caret or a selection inside one textblock.

A delete is fitted the same way. Transform::delete(from, to) is the plain replace when the range’s ends are at the same depth. When they are not (from a list item into the item nested under it, from a paragraph into a list), it fits the empty slice: what is left of the textblock the range ends in joins the one it starts in, as ProseMirror’s Transform.delete does, in one step. deleteSelection, Backspace and Delete over a selection, Enter, insertHorizontalRule, the word deletes and typing over a selection (EditorHandle::insert_text, which deletes the selection and then inserts; Transaction::insert_text alone is the plain replace) all go through it, and leave a caret where the range began. Still refused: a range that leaves or enters a table cell (an isolating node), a range that holds only the boundary between two textblocks that cannot join (the end of a code block and the start of marked text), and insertHardBreak or an image insert over a selection whose ends are at different depths (#1460). A selected list item is emptied, not removed (#1460). tests/review_1422.rs checks every range of random nested documents against those rules.

Step::apply is where schema enforcement lives: a ReplaceStep whose slice would violate the parent’s ContentMatch returns an error and the whole transaction is rejected. Invert is mechanical; map rebases positions through a Mapping; merge coalesces consecutive single-char replaces so typing groups naturally.

Transactions

A Transaction accumulates steps (each applied into a running doc), maps the selection forward as steps are added, and carries stored_marks and plugin meta. You rarely build one by hand — commands do — but the shape is:

#![allow(unused)]
fn main() {
// Inside a command, or via EditorHandle::update:
let mut tr = state.tr();
tr.insert_text("hello").ok()?;     // a ReplaceStep
tr.set_selection(Selection::cursor(Pos(/* … */)));
Some(tr)                           // dispatched and applied by state.apply
}

State, selection, stored marks

EditorState { doc, selection, stored_marks, schema, plugins, plugin_state }

state.apply(tr) runs the transaction’s steps, then folds each plugin’s state forward (history pushes inverted steps). Decorations are not stored and remapped by the core: state.decorations() asks every plugin afresh for the state being rendered, so a plugin that caches decoration ranges maps them through tr.mapping() in its own apply. It returns a brand-new state; nothing is mutated in place, no DOM is touched.

Selection is part of state and is mapped forward by every transaction. It is one of:

  • Text { anchor, head } — a text caret/range (anchor fixed, head moving).
  • Node { pos } — a whole atom selected (an image, a horizontal rule).
  • Cell { anchor_cell, head_cell } — a table-cell rectangle.

The caret is rendered from the selection by the view — there is no second cursor model.

Stored marks carry a tri-state for the “click Bold then type” case:

  • None → inherit marks from the position context on the next insert.
  • Some(vec![]) → explicitly no marks.
  • Some([bold]) → the next inserted text gets bold.

Toggling a mark at a collapsed cursor sets stored_marks; toggling over a range emits an AddMark/RemoveMark step.

Inherited marks and inclusive. With no stored marks, typed text takes the marks of its position (ResolvedPos::marks, ProseMirror’s $pos.marks()):

  • inside a text run: that run’s marks;
  • at a boundary between two runs: the marks of the run before it;
  • at the start of a textblock: the marks of the run after it.

A mark spec’s inclusive flag (MarkSpec::inclusive, default true, builder .inclusive(false)) decides what happens at a mark’s end. An inclusive mark (bold) carries on: type right after a bold word and the new text is bold. A non-inclusive mark does not: at a boundary it is dropped unless the run after the position carries a mark of the same type too, and at a textblock’s start it is dropped. The starter kit’s link is the one non-inclusive mark, as in ProseMirror’s example schema and tiptap, so for a link:

CaretTyped textactive_link_href()
inside the linklinkedthe link’s href
at the link’s startnot linked (the text before decides)None
right after its last characternot linkedNone
right after its last character, with a different link right afterin the first linkthe first link’s href

That last row departs from ProseMirror, where the typed text is plain. With collaboration on, plain text between two links cannot be written to the CRDT without a formatting marker that brings a link back onto text nobody linked when a peer removes the second link at the same moment; text that continues the first link needs no marker at all.

is_mark_active, marks_at and Transaction::add_stored_mark read the same rule. With collaboration on, the typed character is written to the CRDT outside the link as well, in a way that leaves the link’s own formatting untouched, so a peer’s concurrent change to the link — a new href, removing it, extending it over the text after it — survives. What such a concurrent change can do is take the typed character with it: when the peer re-writes the link or links the text after it at the same moment, the character may end up inside the peer’s link (#923). Both editors still end up with the same document.

Commands, keymap, input rules

A command queries the state and, if it applies, builds a transaction and dispatches it — returning true. Called for applicability only, it reports whether it would apply (driving toolbar enabled/disabled state). Toolbar queries read state, never the DOM:

#![allow(unused)]
fn main() {
editor.command("toggleBold");          // dispatch by name
editor.can_run("liftListItem");        // would it apply? (enablement)
editor.is_mark_active("bold");         // toolbar "on" state
editor.current_block_type();           // e.g. Some("heading")
editor.in_node_type("bullet_list");    // ancestor-aware (lists, blockquote)
}

The built-in command catalogue is listed in the Rich-text editing guide: mark toggles, block-type setters, list/blockquote wrapping, indent/outdent, inserts, the full table command set, and undo/redo.

The keymap is the single source of truth for command keys. Each platform view translates its native event into a platform-agnostic KeyBinding and routes it through one entry point, EditorHandle::dispatch_key, which looks up the aggregated Keymap and runs the bound command (so Mod-b → toggleBold everywhere). Letters resolve by the logical key (winit’s layout-mapped logical_key on desktop, event.key() on web), so Mod-b follows the keycap on Dvorak/AZERTY; digits and symbols resolve by the physical key (KeyCode / event.code()), so Mod-Shift-8 matches the 8 key regardless of the shifted glyph. Only keys that can’t be pure editor-core commands stay view-owned: cursor movement (needs laid-out geometry), clipboard (needs the platform clipboard), and plain text insertion. Input rules are regex-driven transforms — block shortcuts like ## → heading, - → bullet list, [ ] → task list, and inline mark shortcuts like **bold** / ==highlight== / `code` — each returning an optional transaction. The view runs apply_input_rules inside EditorHandle::insert_text (before the plain insert) on every text-entry path, so a just-typed character can complete a shortcut and rewrite the text instead of being inserted verbatim (ProseMirror’s inputRules plugin). They only fire at a collapsed cursor; paste and IME preedit don’t reach this path (an IME commit does).

History

There is one history, implemented as a plugin. It stores inverted steps (not byte positions), groups them by transaction boundary, and merges consecutive typing transactions within a time/affinity window, so a burst of typing undoes as one step. On undo it pops a group, rebases its inverted steps over any intervening mappings, applies them as a transaction that doesn’t re-enter history, and restores the recorded selection. undo / redo are the only history entry points.

Plugins

History, tables, links, input rules, and (later) collaboration and accessibility are all plugins — none is special-cased in the core. A plugin can contribute schema nodes/marks, commands, keymap bindings, input rules, per-document state, decorations (a widget such as the placeholder, or an inline class over a document range — a spellcheck squiggle, a search highlight), node-views, and a claim on a paste (handle_paste: the transaction to apply instead of the default paste, asked in plugin order through EditorState::handle_paste). An app adds its own with EditorHandle::add_plugin. This is how features compose without bloating the core.

The view seam

The core defines an EditorView trait and a small request/event vocabulary; it knows nothing about any renderer. The view that implements it — RinchDomEditorView — lives in rinch-editor-view and is itself renderer-agnostic: it projects onto any rinch-core DomDocument, so the desktop (rinch-dom) and browser (web_sys) editors run the same view code. Only the thin input glue and the layout-coupled extras are per-platform (rinch on desktop, rinch-web in the browser).

The view, on each transaction:

  1. Diffs the new document against the descriptor tree it retained from the previous one. Because the model is persistent, Node::same_ref (an Rc::ptr_eq) makes the diff cheap — unchanged subtrees are skipped entirely. Siblings unchanged at the start and the end of a child list are matched first, so a new or removed block leaves the blocks after it on their own host nodes; the stretch between is diffed positionally, and a node whose tag or mark set changed is rebuilt rather than patched.
  2. Patches the host for the changed regions via the standard DomDocument primitives (create_element / create_text / append_child / insert_before / remove / set_text / set_attribute / set_style), choosing tags from the schema-driven serializer. Decorations (placeholder, IME preedit) diff separately, so a decoration-only transaction still produces a visible update.
  3. Renders the caret and selection from state.selection (after layout, in the second phase), converting the model’s char Pos to a byte offset for the platform’s text layout only at this render edge.

Around that, the platform crates own block virtualization (skipping layout for off-screen blocks while keeping their real height), IME positioning, and — under the opt-in a11y feature — pushing an accessibility tree derived from the state.

End-to-end edit flow

key / IME / paste / pointer event
  → the view translates it to a command call or a transaction
  → command(state, dispatch): queries state, builds tr, dispatches it
  → new_state = state.apply(tr)         // schema-validated; reject ⇒ no-op
       • steps applied, selection mapped forward
       • plugins fold state (history pushes inverse, decorations remap)
  → view.update(old_state, new_state)   // diff doc → minimal host patches
       • caret/selection rendered from new_state.selection

No step in this pipeline reads the host tree for content. That is the whole design.

Persisting content

handle.doc() returns the current document Node — the canonical save shape. Enable the serde feature (rinch-editor-core/serde, or serde on the rinch facade) to serialize it to the recursive, schema-derived wire shape and load it back; unknown types are rejected on load, never silently dropped.

Where to go next

  • Rich-text editing — the practical component + command API.
  • examples/markdown-editor and examples/ui-zoo/src/sections/editor.rs — working editors driving the command API.
  • Editor Architecture — the crate boundaries, data flow, and the invariants each boundary protects.

Measuring Performance

Rinch counts the work each frame does. Every document carries a fixed set of per-frame counters: elements cascaded, Parley layouts shaped, whole-document IFC setup passes, Taffy computes, full repaints and why, pixels repainted, hit tests, effect runs, and a few phase timings. The counters are always compiled in. An increment is one store into memory the document already owns, so reading them costs nothing you would notice.

Prefer the counters to timings when you want to prove that a change made something cheaper. Timings move with the machine’s load. A counter moves only when the code does different work: “a colour-only hover now runs 0 Taffy computes instead of 1” means the same thing on every host.

There are three ways to read them.

RINCH_PERF

Set RINCH_PERF (any value) and every completed frame prints one line to stderr: the phase times, then every counter that is not zero.

$ RINCH_PERF=1 cargo run --release -p ui-zoo-desktop
[PERF] frame 212: style 0.08ms layout 0.02ms paint 1.41ms present 0.35ms | style_resolves=1 elements_cascaded=3 ... repaint_partial=1 repainted_px=41236 surface_px=1382400 ...

The variable is read once, the first time a frame ends.

On the desktop, one redraw is one frame. On Android, one pass of the loop that tries to present a frame is one frame. On an embedded RinchContext, one update() plus the scene() call that follows it is one frame.

The perf_stats MCP tool

With the debug feature enabled, the perf_stats MCP tool (debug-protocol command perf_stats) returns this JSON:

{
  "frames": 212,
  "last_frame":    { "elements_cascaded": 3, "taffy_root_computes": 0, ... },
  "current_frame": { ... },
  "total":         { ... }
}

Each object holds every counter, keyed by the counter’s name. Pass reset: true to zero the counters after they are read. That gives you a way to measure a single interaction:

perf_stats(reset: true)       # start from zero
click(x: 120, y: 340)         # the interaction
wait_frame()
perf_stats()                  # `total` now covers only the click and its frames

This is the tool to use for measurements. The DevTools panel is a poor substitute, because while it is open it rebuilds its own tree view on every dirty frame of the app, and that work shows up in what you measure.

From code

  • RinchDocument (rinch_dom): doc.tree.perf is a rinch_dom::perf::PerfCounters.

    • frame() returns the frame in progress.
    • last_frame() returns the last frame that ended.
    • total() returns everything counted since the last reset.
    • end_frame() closes the current frame.
    • reset() zeroes everything.

    A document that nothing drives never ends a frame, so frame() then covers everything since the last reset().

  • RinchApp: last_frame_perf(), total_perf(), reset_perf() and end_perf_frame().

  • RinchContext (embed): perf_last_frame(), perf_total() and reset_perf().

rinch::perf re-exports the module. Each counter is a Counter variant with a stable name(). FrameStats::to_json() and FrameStats::summary_line() produce the two output formats shown above.

#![allow(unused)]
fn main() {
use rinch_dom::perf::Counter;

doc.tree.perf.reset();
doc.set_attribute(row, "class", "row selected");
doc.resolve_layout(800.0, 600.0);
let frame = doc.tree.perf.end_frame();
assert_eq!(frame.get(Counter::TaffyRootComputes), 0, "a colour change must not lay out");
}

The counters

GroupCounterWhat it counts
Stylestyle_resolvesresolve_styles calls, including the synchronous one each DOM insertion makes
elements_cascadedElements that went through a full selector match and cascade
style_nodes_visitedNodes the style walk visited, including cached ones
pseudo_element_passes::before/::after resolutions: one per cascaded element for each of the two that some stylesheet has a rule for, so none in a document with no such rule
full_restyles, full_restyle_{viewport,theme,stylesheet,dpr,root_font_size}Requests for a whole-document restyle, in total and by reason. Two requests can share one walk (a theme change that also changes the root font-size counts twice). A resize counts under viewport only when it flips a media query’s answer
style_invalidationsElements whose attribute or state change Stylo’s invalidator examined — one per changed element per resolve. What it restyled shows in elements_cascaded
viewport_unit_restylesElements a resize that flipped no media query restyled because their last cascade resolved a vw/vh/vmin/vmax (each with its subtree). A resize restyles nothing else
full_style_walksresolve_styles walked from <html>, because a whole-document restyle asked it to or no layout has completed yet
taffy_style_syncs / taffy_style_changesNodes synced to Taffy, and how many of those actually changed their Taffy style
Textshape_measure_ifc / shape_measure_textParley layouts built inside the Taffy measure function
shape_ifc_buildParley layouts built by build_ifc_layouts (the layouts paint uses)
shape_atomic_inlineParley layouts built while sizing an inline-block/-flex/-grid box
ellipsis_buildstext-overflow: ellipsis rebuilds, one per root with an overflowing line
ellipsis_shapeslayouts those rebuilds shaped: 2 for a per-line cut (the “…” and the result, however many lines; one more for a hard-break fallback or a step back from a kerning overshoot), plus one per prefix a whole-text cut’s binary search tried
shape_paintParley layouts built by paint itself: input values, <select> labels, the fallback for uncached text
shape_form_control_metricsParley layouts built to find an <input> / <textarea>’s primary font, whose average character width times size / cols is the control’s intrinsic width (#1177): one per control when first sized and when its font family, weight or style changes
shape_select_labelParley layouts built to size an auto-width <select> from its widest option label: one per label, only when its labels or font changed since it was last sized; and, uncached, one per label each time the desktop native popup opens (to size the panel to its widest row)
ifc_measure_cache_hitsIFC measures answered from the per-root measure cache without shaping
ifc_measure_invalidationsRoots whose cached measures a restyle or content change dropped (O(1) each)
ifc_signature_changesRoots a structural pass found new or changed, and so re-measures; every other root keeps its cached measures and paint layout
ifc_hang_passes / ifc_hang_linesParley layouts broken a second time to hang preserved (pre-wrap, editable) trailing spaces at a soft wrap, or to move a line break parley put after a no-break space it hung (#1218) — at most one per layout, however many lines it fixes — and the lines those passes re-broke
ifc_unglue_rebreaksBreaks of one line made to move a line break parley put after a no-break space it hung (#1218): a handful per such line, logarithmic in the length of an NBSP-glued chain (a galloping search), never one per glued word
ifc_phantom_rebreaksParley layouts broken once more to drop the empty line parley commits after an inline box too wide for its line (#1050), or after a forced break — a <br>, a preserved newline — that ends the text (#1172; CSS makes a line after the last forced break only when something follows it). A min-content measure of a paragraph that ends in an atomic inline pays one, since at width 0 every box is too wide, and so does every layout of a paragraph or text leaf that ends in a forced break
Layoutlayout_resolves, layout_skipped_paint_only, layout_skipped_text_onlyresolve_layout calls, and how many of them took each early return
ifc_setup_passesIFC structural setup passes, scoped or whole-document
ifc_scoped_passes, ifc_scope_containers, ifc_scope_nodesPasses scoped to the formatting containers a structural change reached, and how many containers and nodes they set up again. Everything else keeps its splices, boxes, marks and Taffy cache
ifc_full_passes, ifc_full_{initial,theme,unattributed}Passes over the whole document, and why: the first layout, a whole-document restyle, or NodeTree::ifc_dirty written directly with no reason
taffy_root_computesRoot Taffy computes (the same count as NodeTree::taffy_computes)
taffy_measure_callsCalls to the measure function during root computes
inline_block_computesStandalone Taffy computes that size an atomic inline
calc_fixpoint_passesExtra computes run by the calc(%, px) fixpoint
abs_containing_block_passesExtra computes run because an absolutely positioned box’s containing block — a positioned ancestor that is not its layout parent — came out of a compute at a size the box was not sized for: the document’s first layout, and a resize of that block. Only a box whose size depends on the block (inset: 0, width: 50%) can cause one
abs_boxes_visitedAbsolutely positioned boxes resolved against a containing block the layout engine does not know — a positioned ancestor that is not the layout parent, or the initial containing block — one per box per pass that looks at it: the size check after each compute (ancestor boxes only), the placement as the layout is read back, a second placement when something between a box and its containing block moved late (an anonymous block box read back, an inline-block on its line — on a text-only pass too, as after a text-align change — a clamped scroll offset), and the placement a scroll runs. A box whose containing block is a positioned inline span is counted once more per layout by each of the two passes that follow the lines being built (its size check, and the second placement, which then covers every placed box). An absolute child of its own positioned parent is never counted; a scroll counts only when the scrolled box lies between such a box and its containing block
abs_inline_measuresInline formatting contexts whose lines were measured for the positioned inline spans absolute boxes hang from: once per context per set of lines, however many spans it holds. 0 in a layout that rebuilt no such line
abs_static_lookup_stepsLooks at an inline formatting context’s notes to find an out-of-flow box’s static position in its lines (#632): one per box per placement, however many boxes the lines hold
abs_inline_measure_stepsWhat those measurements looked at — the context’s entries and their ancestors up to its root, its line items and clusters, each span’s own clusters on its first and last line. Linear in the context’s size for any number of sibling spans: twice the badges in one paragraph is twice the steps (spans nested in one another each walk their own clusters)
Paintpaint_frames / paint_cached_framesFrames actually painted, and redraws that reused the cached frame
repaint_partial, repaint_fullSoftware frames limited to their damage, and full repaints
repaint_noneSoftware frames whose scene was marked dirty but whose damage named nothing on screen, so the pixels on screen were kept and nothing was painted (an alt-tab, a focus move onto a box no rule styles)
damage_rectsRects the partial repaints cleared and painted. The damage is a short list (at most 8) of disjoint rects, so two small changes far apart cost their own areas and not the span between them
repaint_full_{first_frame,resize,unattributed,region_too_large,restyle,theme,invalidated,gpu}The reason for each full repaint. region_too_large: the damage’s rects add up to half the surface or more. restyle: the whole document was restyled (a stylesheet, the viewport, the device pixel ratio or the root font-size changed). unattributed: something called RinchApp::mark_scene_dirty, which asks for a frame without saying what changed, and nothing else named any damage. Everything the framework itself changes names its damage — a software GameViewport frame included, since #361 paints it inline — so on a running app this counts only an embedder’s own calls, and a window re-created by the shell
repainted_px / surface_pxPixels repainted, and pixels in the surface (their ratio is the fraction of the surface repainted)
paint_nodes_visitedNodes paint_node visited. A box that would draw nothing — outside the window, outside the damage, or outside every clip the painter has open (a scroller’s rows past its viewport) — is dismissed by its parent’s loop without a visit, so a scroller costs its visible rows, not its list (#910)
removal_damage_stepsWork a removal (or a move to another parent) did to record the old pixels of what it took out: one per node, plus one per ancestor its clip-chain walk stepped through (#909). Linear in what was removed, never in what else is pending
stacking_order_buildsStacking sequences built, by paint and by hit testing
inset_shadow_mask_pxPixels of blurred inset box-shadow computed by paint, cropped to the render target, the open clips and a partial repaint’s damage. Rebuilt on every paint of the box, so a caret-sized repaint inside a large shadowed panel costs about its own area here (#974)
Software painterglyph_cache_hits / glyph_cache_missesGlyphs drawn from the rasterised-glyph cache, and glyphs rasterised (then cached). A steady frame has no misses
clip_masks, clip_mask_pxClip masks pushed, and the mask pixels they were filled and intersected over (each clip’s own bounds, not the surface; none for a clip that fully covers the clip enclosing it, which a scroller around a partial repaint does, and no intersection for one lying wholly inside the enclosing clip’s fully covered area)
paint_layers, layer_pxOpacity layers opened, and the layer pixels composited back (the part of each layer anything was drawn into)
paint_surface_allocsSurface-sized masks and layer pixmaps allocated rather than reused from the painter’s pool. Zero in a steady state
paint_surface_trimsPooled masks and layer pixmaps released at the end of a frame because none of the last 8 frames needed that many at once
image_premultipliesImages premultiplied at draw time. A cached <img> or background-image is premultiplied once, on its first software paint; a live frame source (RenderSurface, video, GameViewport) on every draw — unless every pixel of the submitted frame was opaque, which submit_frame checks on the submitting thread
background_tile_rastersBackground layers painted as one repeating pattern whose tile was rasterised this frame (#468): a new image, gradient or tile size. A repaint that leaves the layer alone is a cache hit, not a raster
background_tile_cache_hitsBackground patterns whose rasterised tile came from the per-thread cache
opaque_image_copiesOpaque frames copied straight into the surface instead of sampled: no rotation or skew, a positive scale, destination edges on whole pixels, and no clip partial where the frame lands. Byte-identical to the sampled draw (opaque_image_copy_tests)
Inputhit_tests, hit_test_nodes_visitedHit tests run, and the nodes they visited
hit_extents_computedSubtree extents the hit tester computed so it can skip subtrees nowhere near the pointer. They are kept until the document or its layout changes, so a run of moves over a still document computes each one once. A scroll keeps them (a scroll container’s extent is its own box), so a wheel notch after the first computes none, not one per row
Reactiveeffect_runs, signal_notifiesEffect bodies run, and signal writes. A memo’s internal marker (which passes a wake on to the memo’s dependents) is not counted, nor is a recompute, nor is a dependent the equality cut-off skipped: effect_runs is the effect bodies that actually ran
rerender_events_queuedReRender events queued to the desktop event loop. At most one is queued at a time — writes between two event-loop turns coalesce into it — so this counts turns that had something to resolve, not writes
Time (ns)time_style_ns, time_layout_ns (time_ifc_setup_ns, time_taffy_compute_ns, time_build_ifc_ns), time_paint_ns, time_present_nsWall-clock time per phase, summed over the frame. Each phase call is timed, never each node; a phase can run several times a frame (every DOM insertion runs the two style phases), so this is a few clock reads per call

Some limits on what these numbers mean:

  • The reactive counters are per thread. Two documents on one thread share them. A desktop window and its DevTools panel are two such documents, as are two embedded contexts. While DevTools is visible, every frame’s effect_runs and signal_notifies include its own work: the shell writes its frame-time and FPS signals on every redraw, and its panels re-run. Close DevTools before reading the reactive counters.
  • A frame repaints what its damage names, and nothing else. The damage is the paint-dirty nodes (where each is now and where it was last painted), the rects of removed nodes, and the drag ghost’s and inspect highlight’s old and new rects. A box whose place in the paint order changed — it started or stopped being a stacking context (opacity crossing 1, a transform or filter starting or ending, position: fixed/sticky) or a positioned layer, or its z-index moved, by a restyle or by a transition or animation frame — names everything its subtree paints, since a z-index descendant that overflows it changes order against the boxes beside it. Such a frame repaints the whole window when the subtree cannot be measured: it holds more than 512 nodes, a position: fixed or sticky box, or an absolute box that escapes an overflow box at or under the one that flipped. A fade or a transform on a long list is therefore two full repaints (its first and last frame), where the frames between repaint the list’s own rect. A change that reaches pixels without a DomDocument write must name its node (NodeTree::mark_paint_dirty) or call RinchApp::mark_scene_dirty, which is counted as repaint_full_unattributed.
  • The GPU backend always repaints in full. Every painted frame on that backend counts as repaint_full_gpu.
  • The software-painter counters are the desktop and Android shells’ only. TinySkiaPainter counts into its own SkiaPainterStats (stats(), take_stats()), which the shell folds into the frame after each software paint. A test painting through paint_document directly reads the painter, not the document.
  • What the software painter keeps between frames. Its glyph cache (cleared past about 16 MB of accounted bytes or 512 font/size runs); a premultiplied copy of each translucent cached image; and a pool of up to 8 clip masks (1 byte per surface pixel each) and 8 layer pixmaps (4 bytes per pixel each — 33 MB at 4K). The pool is trimmed after every software frame to the most any of the last 8 frames had open at once, so an app that idles right after a deeply nested frame keeps that frame’s buffers until it paints again.
  • A screenshot is a frame. The debug screenshot command paints, and that paint shows up in the counters like any other frame. The capture after it adds nothing: on the software backend it reads the frame just presented back, as the GPU backend always has, rather than painting a second time (#364).

The committed baselines

crates/rinch-dom/tests/perf_counter_baselines.rs runs the paths that matter for responsiveness on a 40-row list and asserts today’s counter values:

  • idle
  • a colour-only hover, with and without a display: contents wrapper
  • a colour-only class toggle
  • an attribute no selector reads (nothing is cascaded)
  • a container class a descendant rule depends on
  • appending a row
  • removing a row
  • setting one text node’s content
  • a 1px resize, with and without vw rules
  • a transform animation tick

crates/rinch/src/app/perf_stats_tests.rs covers the counters only the shell fills: the full-repaint reasons, the dirty region, hit testing, and the reactive deltas. It also pins the pointer-move path: a warm move over a 500-row scroller runs one hit test that visits the four boxes under the pointer and builds no stacking sequence, a move with no onmousemove handler anywhere runs one hit test, and five drag moves queued before a frame lay out once. A press shares its hit test the same way: a click on an editor toolbar button runs one hit test for the press and one for the release (perf_regression_editor_tests::a_toolbar_bold_over_a_word, #908).

Each scenario in perf_counter_baselines.rs asserts the whole frame: every counter’s exact value, with any counter the baseline does not list expected to be zero. perf_stats_tests.rs is exact on the counters each test names and does not check the rest of the frame. That catches a path that got more expensive and, just as important, an increment that stopped counting, which an upper bound alone would read as a saving. A test in the same file checks that every counter rinch-dom owns fires on some path.

A performance fix is expected to change these numbers. Updating the baseline to the new values is the proof that the fix worked; say in the PR which counters moved and why. PERF_BASELINE_PRINT=1 prints each scenario’s frame in the form the baseline is written in.

The file’s #[ignore]d perf_scenario_timings prints wall-clock times for the same scenarios, and structural_timings_at_scale times an append, a removal and a toggle on 500 and 2000 rows, with and without a display: contents wrapper per row. Run them in release:

cargo test --release -p rinch-dom --test perf_counter_baselines -- --ignored --nocapture

The regression scenarios

Three more files hold scenarios under the same exact-count contract. They are new files rather than new rows in the two above so that parallel performance PRs do not collide in one file.

  • crates/rinch-dom/tests/perf_regression_scenarios.rs has one scenario per increment site of every counter that has more than one site. shape_paint has three (a <select> label, an <input> value, and text with no cached layout — a constructed state since #904, when an inline-flex label stopped reaching it), shape_atomic_inline two and pseudo_element_passes two. ellipsis_builds is down to one site, an IFC root’s: the text-leaf rebuild was deleted in #982 once #998 had closed the last route to it, and the file keeps a scenario pinning that a flex container’s own text builds none (Chrome draws no “…” there). A per-counter check cannot see a deleted increment while a sibling site still fires; one document per site can.
  • crates/rinch/src/app/perf_regression_tests.rs drives a real RinchApp the way the desktop loop does (AboutToWait, then the paint the loop asked for):
    • idle: a plain page, a focused <input>, a paused animation, a running Loader, and a Loader in a closed Drawer (which idles: the drawer’s closed rule pauses it, #912);
    • keyed for over 200 rows: move one row, insert one, remove one, and replace them all;
    • a wheel scroll over 500 rows;
    • ten queued Drag::absolute moves;
    • a theme toggle and a scale-factor change;
    • one frame per full-repaint reason: first_frame, resize, unattributed, region_too_large, restyle, theme, invalidated and gpu, plus repaint_none.
  • crates/rinch/src/app/perf_regression_editor_tests.rs covers a 30-paragraph editor: idle while focused, one caret blink, typing a character, ArrowRight, Enter, Backspace across a block boundary, and the toolbar’s Bold.

Each scenario also asserts a positive control, a check that it did what its name says (a repaint happened, a row moved, the document grew by one character). Without it, an idle scenario whose app never mounted would pass as a perfect idle.

Some of these scenarios do work they should not, and they pin it as it is. A scenario like that says so in its doc comment and names the counter. A fix then shows up as a baseline that falls.

The baselines are the contract

Four files assert whole frames: perf_counter_baselines.rs and the three regression-scenario files. Each checks every non-timing counter exactly, and any counter the baseline does not list must be 0. perf_stats_tests.rs is exact only on the counters it names. Two counters are left out of the whole frame: the time_* counters, which are wall-clock, and rerender_events_queued in the shell scenarios, which is folded in from a process-wide atomic. queued_rerender_events_are_folded_into_the_frame pins that counter on its own, under a lock it shares with the one other test that writes the atomic.

The numbers must not depend on the host’s fonts. A declared line-height pins only the vertical axis. A width, a caret step or a repaint rect still comes from the glyph advances of whatever font answered, so a scenario whose numbers include horizontal text geometry sets its text in the bundled Inter:

  • the shell scenarios build every app with perf_expect::new_app, which makes Inter the sans-serif, and set all text in sans-serif;
  • the rinch-dom scenarios register Inter under their own family name.

Before this rule, the editor’s ArrowRight repainted 608 px on one machine and 640 px on CI (Noto Sans against DejaVu Sans). Check a new scenario under a second font set before you push: point FONTCONFIG_FILE at a config that maps sans-serif to DejaVu Sans, and run it both ways. The two runs must print identical frames.

A pinned finding names its issue. Some scenarios pin work that should not happen (issues #904–#914). Each one’s doc comment names its issue and says that a fix must lower the number. A fall there is the fix proving itself, not an increment that stopped counting.

This makes the baselines the performance contract for every change:

  • A PR that changes a baseline must explain it in its body. For each scenario that moved, list the counters that moved, old and new, and why. The pull request template has a Performance section for this.
  • A counter that rose is a path that got more expensive. Either fix it, or accept the cost and say why.
  • A counter that fell is either a fix proving itself or an increment that stopped counting. The PR says which.
  • Never loosen an assertion to make it pass. Do not turn an exact value into an upper bound, and do not remove a counter from a baseline. A value that honestly varies from run to run (the rotated Loader’s repainted area depends on the angle the clock lands on) gets a range with its derivation written next to it. Every other counter in that frame stays exact.

Updating a baseline

When an assertion fails, it prints the frame it saw in the form the baseline is written in, ready to paste:

editor: type one character: the frame differs from its recorded baseline.
  taffy_root_computes: 1 (baseline 2)
  ...
Actual frame:
            (StyleResolves, 2),
            (ElementsCascaded, 2),
            ...

PERF_BASELINE_PRINT=1 prints every scenario’s frame, passing or not. Use --test-threads=1 so the frames do not interleave:

PERF_BASELINE_PRINT=1 cargo test -p rinch-dom --test perf_regression_scenarios -- --nocapture --test-threads=1
PERF_BASELINE_PRINT=1 cargo test -p rinch --lib perf_regression -- --nocapture --test-threads=1
PERF_BASELINE_PRINT=1 cargo test -p rinch-dom --test perf_counter_baselines -- --nocapture

Run the shell scenarios with CI’s feature set as well. The theme-toggle and gpu scenarios compile only with theme and with gpu or embed:

cargo test -p rinch --features desktop,embed,theme,clipboard,debug --lib perf_regression

Reading a frame

The names are the ones RINCH_PERF prints and perf_stats returns. To find out why a number moved, run the same interaction in a live app:

  1. Call perf_stats(reset: true).
  2. Do the interaction.
  3. Call wait_frame(), then perf_stats().

Or set RINCH_PERF=1 and read the one line each frame prints. Close DevTools before you read the reactive counters (see the limits above).

What a blurred text-shadow costs

A text-shadow with a blur radius is rasterised into a coverage mask, blurred and filled once per shadowed block of text (#980). The masks are kept between paints, so a repaint of shadowed text that has not changed, and is not cut by the edge of the window, a clip or the damage, pays only the fill — a scroll included, once each whole-pixel position a scroll can put each glyph at has been drawn (a sub-pixel scroll changes a line’s mask only where it moves a glyph to another pixel). A shadow the window, a clip or the damage cuts is rasterised again whenever that cut moves across it. The text_shadow_masks_rasterised counter says how many a frame rasterised. The first paint of a page of blurred shadows costs about twice what the same page with an unblurred shadow does, and more the larger the blur and the display scale: the mask is the text grown by one and a half blur radii on every side, in physical pixels. On a build with no software rasteriser (embed without software-renderer) a blurred shadow is drawn as up to 13 copies — fewer, or none, for a shadow of more than 153 glyphs, decided by that shadow alone.

CI regression job

Counters say what a frame did; they do not say how much it cost. The Perf workflow (.github/workflows/perf.yml) covers cost. On every pull request it counts the instructions each hot path executes. It counts them on the merge commit GitHub builds for the PR, and on that commit’s first parent, the tip of main it was merged onto. It fails when one count grew by more than a threshold. Every run re-bases on main as it is at that moment, so the Δ says what merging the PR would do now. Changes other PRs landed on main are not counted against it.

It counts instructions and not time because a hosted runner’s timings move with whatever else shares the machine, while an instruction count moves only when the code does. The counts come from Valgrind’s Callgrind, driven by Gungraun (the renamed iai-callgrind). The benchmarks live in crates/rinch-bench:

BenchmarkWhat one run measures
dom::hover.list_500:hover on one row of a 500-row list (display: contents wrappers, an inline-block chip per row), then layout
dom::class_toggle.list_500A colour-only class change on one row, then layout
dom::append_row.list_500 / remove_rowAppend or remove one row, then layout
dom::resize_1px.list_500Layout at a viewport 1px wider
dom::set_text_content.list_500set_text_content on one row’s wrapped text, then layout
dom::flex_label_hover.list_500A colour-only :hover on one of 500 display: flex rows whose text is a direct child (a flex item’s text leaf), then layout and paint. Paint colours a leaf itself, so no compute runs (#904)
dom::drawer_toggle.list_500_closedOpen, then close, a closed Drawer holding 500 rows, with the theme’s and the component library’s stylesheets loaded; each re-cascades the whole subtree. The one benchmark that sees what a component-library selector costs (#935: a pseudo-element rule is matched without a bloom filter)
dom::inset_move.list_500One drag step of an absolute panel beside the list: set_styles of left/top (the inset fast path), then layout
dom::abs_badge_relayout.direct_500 / icb_500One leaf restyled, then layout, in a scroller of 500 rows that each hold an absolutely positioned badge: a child of its own position: relative row (direct), or a box with no positioned ancestor (icb). Neither needs a size check after the compute, and the first is in none of the out-of-flow passes at all (#386)
dom::abs_badge_scroll.direct_500 / ancestor_500One scroll of that scroller with no layout. ancestor puts a static wrapper around each badge, so its row is a containing block Taffy does not know; the scroller is still between no badge and its containing block, so the scroll re-places nothing
dom::full_paint.text_page_warmA full TinySkiaPainter paint of 40 wrapped paragraphs, with the glyph cache already warm
dom::shadow_paint.fields_40A full TinySkiaPainter paint of 40 input-like boxes, each with a 1px border, a 6px radius and a blurred inset shadow (#974: a blurred mask built and drawn per box)
dom::text_shadow_paint.paragraphs_40The same 40 paragraphs with text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4) on each, painted again: every blurred mask is served from the cache (#980)
dom::text_shadow_paint.paragraphs_40_coldThe same page restyled to a 4.01px blur first, so no mask is cached: every visible shadow is rasterised into a coverage mask, blurred and filled
shell::pointer_move_warm.warm_x5050 pointer moves inside one row of a 500-row scroller, each followed by AboutToWait
shell::pointer_move_cold.coldThe first move after a layout, which builds the hit-test cache
shell::hover_frame.partial_repaintA move onto another row, then the frame: layout and a partial software repaint
shell::keyed_for.reverse_200Reverse a keyed for of 200 rows, then the frame
shell::memo_flush.selection_40Move the selection among 40 rows, each with a Memo<bool> and an effect: the signal write and its effect flush
shell::branch_hide.rows_1000Hide an if branch holding a keyed for of 1,000 rows and a nested captured handle (issue #732: one walk detaches the handle and drops the ownership records, then the discard), then the frame
editor::collab_keystroke.paragraphs_2000One keystroke in the middle of a collaborating 2,000-paragraph document, projected onto the CRDT (CollabSession::record_local): reads back the paragraph it changed and no other (#1229’s first round read back all 2,000)

Each benchmark builds its fixture in a setup that Callgrind does not count. The setup also runs the operation once where that warms a cache, so what is counted is the second hover and not the first. All text is set in the bundled Inter-Regular.ttf, so no scenario measures or draws through the host’s fonts. That does not make a count portable: resize_1px is 111.2M instructions on the CI runner and 147.6M on a developer workstation, because the toolchain, glibc’s CPU-specific memcpy and the CPU all move it. Compare two counts only when one machine produced both. The CI job runs base and head on the same runner for this reason, and a local before/after is only comparable with another local run.

Reading the report

The job writes a table to its summary page and to one comment on the PR, which it edits in place on each push. A row looks like this (the numbers are from a deliberately slowed update_hover):

BenchmarkbaseheadΔ
dom::hover.list_500177,782185,785+4.50%❌ regression

The columns are Callgrind’s Ir, instructions executed inside the measured operation. Two runs of the same binary agree to within about 0.03%. The remaining noise comes from hash-table probing, whose seeds vary per process. So any Δ larger than a fraction of a percent is the code.

The base run first uses the head’s copy of crates/rinch-bench, so both sides run the same scenarios and every Δ is the library alone. That copy does not build on the base when the PR adds a benchmark that calls an API the same PR introduces (#1036). The base then runs its own copy instead, restored with its own Cargo.lock (.github/scripts/perf_bench_base.sh). Every benchmark both copies define is still compared; one only the head defines shows as new, and one only the base defines as removed. Above the table, the report always says how many benchmarks were compared and names the ones that were not.

Two copies of the sources are only comparable while the head’s copy merely adds to the base’s: new functions, new #[bench::…] cases, new entries in a group list. A PR that also edits an existing scenario could make it cheaper and hide a real regression behind a small Δ. So in fallback mode .github/scripts/perf_bench_changes.py looks for any line the head’s copy removes or rewrites, and any line it inserts inside an existing item (a new statement in an existing setup). If it finds one, the job fails and the report lists each edit with the function it is in; the perf-regression-accepted label downgrades that to a warning, and the PR then says what the scenario change was. The check is a text proxy, not a Rust parser, and errs towards reporting an edit.

When neither copy runs, the outcome depends on whether the base has the crate at all:

  • The base has no crates/rinch-bench. The report shows the head alone and the job passes. No merge commit’s first parent can lack the crate once it is on main, so this applies only to PRs based before then.
  • The base has the crate and neither copy ran. The job fails. Usually one of the base’s own benchmarks panics there. Otherwise, breaking the benchmarks would be a way past the check. The perf-regression-accepted label downgrades this failure to a warning, the same as a regression.

The scripts the job runs have a self-test against a fake cargo (python3 .github/scripts/test_perf_scripts.py), which the job runs first.

If the head’s own benchmarks do not build or run, the job fails. The PR comment then says so, rather than keeping the previous run’s table.

The threshold and the escape hatch

The job fails when any benchmark’s Ir grows by more than 3%. Change the threshold with the repository variable PERF_REGRESSION_THRESHOLD, a number of percent.

A PR that makes a path more expensive on purpose can carry the perf-regression-accepted label. With the label, the regression is still reported, as a warning, and the job passes. Adding or removing the label runs the job again. So does changing the PR’s base branch to main: that run comes from .github/workflows/base-changed.yml, which calls perf.yml, and its check is named perf / instruction-counts. A docs-only PR, which the Perf workflow otherwise skips, gets one run there. Say in the PR which benchmark moved and why, in the pull request template’s Performance section, the same way a counter-baseline update does (see The baselines are the contract). The two checks complement each other. The baselines are exact and say which work a frame did, while the instruction counts say what that work cost. A change can move one without the other.

The comment is posted by a separate comment job, which is the only job with pull-requests: write and runs none of the PR’s code. It updates the comment that carries the <!-- rinch-perf-bench --> marker and is authored by github-actions[bot], so a human comment that quotes the marker is left alone. A pull request from a fork gets a read-only token and cannot comment. For those PRs the report is on the job’s summary page only. A PR that changes only docs/** or Markdown files does not run the job.

The cargo cache holds only dependency artifacts. A fresh checkout makes every workspace crate look stale to cargo, so those rebuild on every run anyway. Only a push to main writes the cache, and only when its key misses. The key hashes every Cargo.lock and Cargo.toml, the toolchain and the compiler environment. Pull requests only read it.

Base and head are both compiled with the Rust version the head’s rust-toolchain.toml pins. A pull request that moves the pin therefore shows no difference from the new compiler’s code generation, and its dependency build is cold. The job does not measure what a toolchain bump itself costs.

Allocation is not what it costs in a real frame

glibc’s malloc and free cost a different number of instructions depending on what the heap already holds. Which blocks can merge and which bin a free lands in depend on everything the process did before, and the order of a setup’s allocations follows HashMap iteration, which std seeds randomly. Measured on set_text_content, identical runs gave 5.34M, 5.71M and 5.83M instructions, and every instruction of the difference was inside malloc.c.

So the benchmark binary installs rinch_bench::alloc::BenchAlloc. Inside the measured operation, every allocation bumps a pointer into a region reserved in setup, and every free does nothing. Both cost the same few instructions each time. Outside the operation, the system allocator handles everything. The cost: an allocation counts as a few instructions instead of glibc’s 50 to 150, so a change that only adds allocations moves the count less than it moves a real frame. The code that builds what it allocates is still counted in full.

Running it locally

You need Valgrind and the Gungraun runner, at exactly the version of the gungraun library in Cargo.lock:

sudo apt-get install valgrind
cargo install gungraun-runner --version 0.19.4 --locked
cargo bench -p rinch-bench                      # all benchmarks, human-readable
cargo bench -p rinch-bench -- '*::hover::*'     # one benchmark (a glob over the module path)

Each run is compared with the previous run of the same benchmark on disk, so running before and after a change prints the Δ directly. To reproduce the CI table, save each side as JSON and compare the two files:

cargo bench -p rinch-bench -- --output-format=json --callgrind-args=--cache-sim=no > before.jsonl
# ...make the change...
cargo bench -p rinch-bench -- --output-format=json --callgrind-args=--cache-sim=no > after.jsonl
python3 .github/scripts/perf_compare.py --base before.jsonl --head after.jsonl

Each benchmark leaves a Callgrind profile at target/gungraun/rinch-bench/hot_paths/<group>/<bench>/callgrind.*.out. callgrind_annotate --inclusive=yes <file> or KCachegrind shows where the instructions went.

A full run takes about 40 seconds on a 24-core workstation with --parallel=4. Valgrind runs code about 50 times slower than native, and the benchmarks are sized for that: 500 rows where the operation touches one row, 200 where it touches every row.

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/        # Platform-agnostic RinchApp
│   │   └── src/shell/      # Desktop backend (winit + wgpu)
│   ├── rinch-core/         # Core types, reactive primitives, DOM abstractions
│   ├── rinch-macros/       # rsx! proc macro
│   ├── rinch-dom/          # HTML/CSS DOM (Taffy + Parley + Stylo + Vello)
│   ├── rinch-platform/     # Platform abstraction traits
│   ├── rinch-web/          # Browser-native DOM backend (WebDocument over web_sys)
│   ├── rinch-editor-core/  # Rich-text editor model (pure, renderer-agnostic)
│   ├── rinch-editor-view/  # Rich-text editor view (desktop + web projection)
│   ├── rinch-editor-collab/# Optional CRDT collaboration adapter (yrs)
│   ├── rinch-theme/        # Theme system (CSS variables)
│   ├── rinch-components/   # UI component library
│   ├── rinch-editable/     # Single-line <input>/<textarea> editing primitives
│   ├── 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

These are the principal crates; the workspace Cargo.toml has the full member list (networking, storage, media, testing and platform-specific crates are omitted here).

Layer Diagram

┌──────────────────────────────────────────────────────────────┐
│                     Application Layer                         │
│  (your app, ui-zoo-desktop, ui-zoo, etc.)                         │
├──────────────────────────────────────────────────────────────┤
│                         rinch                                 │
│  ┌──────────────────────────────────────────────────┐        │
│  │          RinchApp (app/)                          │        │
│  │  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)   │  (component library)         │
├──────────────────────────────────────────────────────────────┤
│                    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 {
    pub fn handle_event(
        &mut self,
        event: PlatformEvent,
        window_size: (u32, u32), // physical pixels (pointer coords are logical)
        scale_factor: f64,
    ) -> Vec<AppAction> {
        match event {
            PlatformEvent::MouseDown { x, y, button } => {
                // Handle click, update DOM
                vec![AppAction::RequestRedraw]
            }
            PlatformEvent::KeyDown { key, modifiers, .. } => {
                // Handle keyboard input
                vec![AppAction::RequestRedraw]
            }
            // ...
        }
    }
}
}

window_size is always the physical surface size — the one genuinely physical quantity crossing this boundary. Every shell converts it to the logical (CSS-pixel) layout viewport with the shared rinch_platform::to_logical — never by dividing inline — so that mount, resize and every other relayout agree on the same viewport. Inside RinchApp this conversion happens once per handle_event call via RinchApp::layout_viewport.

The pointer coordinates carried by PlatformEvent are the opposite: they are logical on every host, and it is each shell’s job to convert before constructing the event, with rinch_platform::to_logical_point. The document is laid out in CSS pixels and hit_test probes that layout tree directly, so a shell that forwards its windowing system’s physical pointer position displaces every click by the scale factor times its distance from the window origin (issue #299 — the desktop shell did exactly this until it was fixed). MouseWheel’s pixel delta converts the same way.

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 → PlatformEvent → RinchApp.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::new(), Effect::new(), Memo::new(), plus scopes/ownership and create_context() / create_store()
  • DOM abstractions - RenderScope, NodeHandle, DomDocument trait
  • Control flow - show_dom(), for_each_dom_typed(), match_dom() (what rsx if/for/match desugar to)
  • Element types - Minimal enum: Html, Fragment, Component only
  • Event handling - Input, click, keyboard and drag event dispatch

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/) - Platform-agnostic application logic (event dispatch, hit testing, focus arbitration, text selection)
  • Desktop backend (shell/rinch_runtime.rs) - Event loop, window creation, rendering
  • Menu Manager - Native menu support via muda
  • Editor wiring (editor/) - Desktop-only editor pieces: block virtualization, AccessKit accessibility, the cross-thread collaboration inbound

rinch-platform

Platform abstraction layer defining cross-platform traits:

  • Traits - PlatformWindow, PlatformRenderer, PlatformEventLoop, PlatformMenu
  • Types - PlatformEvent, AppAction, KeyCode, Modifiers, MouseButton

rinch-dom

HTML/CSS DOM implementation:

  • RinchDocument - Implements DomDocument trait from rinch-core
  • Layout - Taffy for flexbox/grid layout
  • Text - Parley for text shaping and line breaking
  • Styling - Stylo for CSS parsing and computed styles
  • Rendering - Vello (GPU) or tiny-skia (software) via the Painter trait abstraction

rinch-web

Browser-native DOM backend (the WASM target). Consumed by the *-web examples — ui-zoo-web, paint-web, editor-web, collab-editor-web, and others:

  • WebDocument - Implements DomDocument via web_sys, creating real browser DOM elements
  • No Taffy/Parley/Vello - The browser handles layout, text shaping, and painting natively
  • Event delegation - setup_event_delegation installs document-level listeners that dispatch via data-rid attributes (plus drag, render-surface routing, and data-onsubmit/data-oninput)
  • mount helper - One call wires the WebDocument, builds the component tree, installs event delegation, and injects theme CSS
  • Rich-text editor - Re-exports Editor / EditorHandle / create_editor from rinch-editor-view and owns the browser input glue, so editor app code is identical to desktop
  • 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-core / rinch-editor-view / rinch-editor-collab

The ProseMirror-style rich-text editor, split by how much each crate may know about a renderer:

  • rinch-editor-core — the model-first source of truth, and pure: no rinch-dom, winit, web_sys, parley, taffy, vello, or CRDT engine, and it compiles to wasm32. It holds the immutable Node/Mark/Fragment/Slice document tree, one char-based Pos space, a ContentMatch schema that is enforced at the step boundary, invertible Steps, EditorState/Transaction, the command catalogue and keymap, markdown input rules, Step-based undo/redo, and total schema-derived serialization (DocNode JSON, HTML, markdown). It defines the EditorView seam and knows nothing about any renderer.
  • rinch-editor-view — the projection: RinchDomEditorView renders an EditorState onto any DomDocument host, so the desktop (rinch-dom) and browser (web_sys) editors share one view. Also the Editor {} component, the imperative EditorHandle, the mounted-editor registry and the default stylesheet.
  • rinch-editor-collab — optional, off by default: projects the model onto a yrs (Yjs) CRDT for real-time collaboration and rebuilds remote changes back into Steps. The only crate in the workspace that links a CRDT engine.

Mutation flows one way: every edit is a Transaction applied by EditorState::apply, after which the view diffs the document and patches the host. The host tree is never read back for content, and commands query state, never the DOM.

See Editor Architecture for technical details.

rinch-components

UI component library:

  • 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

Editing primitives for single-line <input> and <textarea> widgets — a separate engine, unrelated to the rich-text editor above (they share no code and no types, despite both having a Selection):

  • EditableDocument - Trait for text editing operations
  • StringDocument - Single-line text document
  • EditCommand - Enum of editing commands (insert, delete, etc.)
  • EditableState - Cursor position, selection state
  • InputHandler - Maps keyboard input to commands

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
yrsCRDT for collaborative editing (rinch-editor-collab, opt-in)

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();   // detach — the subtree can be inserted again
node.discard();  // finished with it — the backend may let go
}

remove() and discard() are not synonyms. A removed node stays re-insertable on every backend (issue #719), which is what lets a reactive branch re-show a captured handle; discard() is how you tell the backend you are finished, and after it the ids name nothing. See RenderScope & NodeHandle.

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

Attribute names are ASCII case-insensitive in HTML content and case-sensitive in SVG content (issue #688), as in a browser. set_attribute("ID", …) on a <div> stores id, so #up, [id], getAttribute("id") and an uppercase STYLE= (which has to reach the inline-style cache) all agree; get_attribute and remove_attribute fold their own name the same way, so either spelling finds what either spelling wrote. On an element whose tag is an SVG one the author’s spelling is kept, because viewBox, preserveAspectRatio, gradientUnits and their kind are distinct names in SVG.

The decision is made from the element’s tag, not from an <svg> ancestor, because rsx! and the Element::Html parser both write an element’s attributes before appending it to its parent — at the write there is no ancestor to walk. crates/rinch-dom/src/attr_name.rs holds the tag list. The attribute value is never folded: an id still matches #CamelId case-sensitively.

A form control’s live text: live_value

get_attribute("value") reads the value attribute, and on the web that is not what a text field shows once the user has typed: typing moves the element’s .value property and leaves the attribute at whatever was last written programmatically. live_value() asks the question a component usually means — “what does the field say right now?” — on either backend (issue #238):

#![allow(unused)]
fn main() {
if input.live_value().as_deref() != Some(new_text.as_str()) {
    input.set_attribute("value", &new_text);
}
}
  • Desktop answers with the value attribute, which is the live text there: the runtime mirrors each edit into it before dispatching oninput, and adopts a programmatic write to the focused field back into the text engine.
  • Web answers with the .value property of an <input>, <textarea> or <select>; any other element answers with its attribute.

None means the node carries no value this backend can name — on desktop, a control with no value attribute yet. A web form control always answers Some, "" when empty. Compare against the text you are about to write and treat None as “differs”.

TextInput, PasswordInput, Textarea and NumberInput bind value_fn with exactly that guard (rinch_components::value_binding::bind_value_fn), so the echo of the user’s own keystroke is never written back; a custom component binding a value_fn can call the same function.

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
}

set_style means what style.setProperty means in a browser: afterwards the element’s inline style gives the property that value, whatever the attribute declared before. On the web it is setProperty. On desktop the attribute is a string of declarations and a property already in it is rewritten where it stands — unless a declaration after it covers it (a shorthand such as inset after left, a longhand after a shorthand, or a logical twin such as inset-inline-start after left), in which case the write moves to the end so that it wins, as the browser’s longhand list makes it win (issue #470). One case still differs: a later covering declaration marked !important keeps beating a normal desktop write (issue #1298).

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

Removal is a detach; discard() is the end of a node’s life. remove() and replace_with() take a node out of the tree and leave it re-insertable on every backend: append the handle again and the whole subtree comes back, and you may read, style or restructure it while it is out. That is what a reactive branch re-showing a captured handle rests on (issue #719).

Because a removed node is still the backend’s to keep, tell it when you are finished with the subtree for good:

#![allow(unused)]
fn main() {
panel.remove();    // hidden for now — I may show it again
row.discard();     // gone for good — let go of it
}

Treat a discarded handle as dead: build a fresh node rather than reviving one. A backend that retires makes every operation on it a silent no-op, and rinch-web and the test MockDomDocument both do, so the mistake fails cargo test as well as a browser. A discarded id is never handed to a different node on either backend, so a stale discard handle can only name nothing. (That is a claim about discard alone: rinch-dom frees slab keys through set_inner_html and pseudo-element pruning, and those are recycled — issue #304, live today and independent of this API.)

A subtree you cache from inside a for body, a branch or a render_fn must not be built through the scope that closure is handed: that scope owns what it builds, and the helper discards it when the row or branch goes. Build it through scope.cache_scope() (with build, so its signals and handlers are the cache scope’s too), keep that scope beside the node, and when the cache lets go — including when its owner unmounts — dispose() the scope and discard() the node: dropping the scope alone leaves the node in the backend. See A cache filled from inside a for (issue #733).

Which to use. If the same handle can be inserted again, remove(). If it cannot — a pool shrunk, a glyph replaced, a panel rebuilt — discard(). Reaching for remove() where you meant discard() costs memory; reaching for discard() where you meant remove() costs the subtree. Neither is reported, so choose deliberately.

The reactive helpers do not choose by hand. show_dom, match_dom, reactive_component_dom and for/virtual_list rows each run their user closure inside a RenderScope of their own, and go by ownership: a node that scope minted is the helper’s to discard, a node the closure was handed is the caller’s and is only detached. RenderScope::created is the whole rule, and it is the rule #141 gave signals and effects, applied to nodes. It is why if open { p { "hi" } } reclaims its markup on every hide while if open { {panel} } keeps yours.

Two edges of that rule. The verb is chosen before the scope is disposed, so an on_cleanup that re-parents a scope-built node cannot rescue it — build it outside the closure and hand it in instead. The verb is applied after the scope is disposed, in all four helpers (a for or virtual_list row since issue #356), so an on_cleanup still sees its own node live and mounted — on rinch-web a node already discarded answers every read with None. And ownership answers for nodes in a subtree: a node attached to nothing is reached by no walk, which is what rinch_core::dom::release_scratch_container exists for.

remove()’s post-condition is the same on both backends; discard()’s is not. rinch-web holds a strong web_sys::Node in two page-global maps, so a discard() is what releases the browser node against GC (issue #184). rinch-dom reclaims nothing: a discarded node there still re-inserts, still keeps its subtree and still takes writes, because its slab is per-document and freeing the slot would recycle the id (issue #304) — so the desktop slab only grows, which is issue #723. The contract is therefore one-sided: a discard() is at least a remove() and may be much more, and you may not rely on it being less.

Focus

#![allow(unused)]
fn main() {
let input = __scope.create_element("input");
input.focus();  // Give focus to this element
}

An overlay also has to read the current focus and give it back later, so three more handle methods answer the questions focus() alone cannot (issue #695):

#![allow(unused)]
fn main() {
let opener = panel.active_element();                 // who holds it right now
panel.focus_into(FocusIntoPolicy::FirstFocusable);   // showModal()'s focusing steps
// …later, when the overlay closes:
panel.restore_focus(opener.as_ref());                // give it back, or let it go
}
  • active_element() is a document-level question reached through a handle, like set_scroll_locked. None means unknown — on the web, an element rinch did not create carries no __nid and cannot be named — not nothing is focused.
  • focus_into(policy) moves focus into this subtree the way showModal() does — the autofocus descendant if there is one, else the first focusable under FocusIntoPolicy::FirstFocusable and nothing under AutofocusOnly. “The first focusable” is each backend’s own computation, which is why this is a backend method rather than a walk a component could write.
  • restore_focus(opener) is the close half, and it is one call rather than a blur plus a hopeful focus because the caller cannot make the decision: whether the keyboard is even this overlay’s to return, and whether opener can still take it, are both questions about boxes.

Desktop resolves both overlay calls after the next layout, since the boxes are a layout out of date at the moment an effect makes them; the web answers immediately and lets the browser arbitrate. There is no bare blur(): releasing the keyboard is only ever the fallback half of a restore.

rinch-components’ overlay_focus::arm_overlay_focus is the one in-tree caller, and the focus guide describes the behaviour it builds.

NodeHandle API Reference

MethodDescription
set_text(content: &str)Set text content (for text nodes)
set_attribute(name: &str, value: &str)Set an attribute — the name folds to lowercase in HTML content, verbatim in SVG (#688)
get_attribute(name: &str) -> Option<String>Get an attribute value (same fold)
remove_attribute(name: &str)Remove an attribute (same fold)
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 — a detach; the handle and its subtree stay re-insertable
replace_with(new_node: &NodeHandle)Replace this node with another — also a detach; the displaced handle stays re-insertable
discard()Remove this node and release the backend’s bookkeeping for its whole subtree — retires the ids
focus()Give focus to this element
active_element() -> Option<NodeHandle>Who holds the keyboard in this node’s document
focus_into(policy: FocusIntoPolicy)Move focus into this subtree (showModal()’s focusing steps)
restore_focus(opener: Option<&NodeHandle>)This overlay has closed: hand the keyboard back, or release it
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)

Watching a subtree change

A container whose prop is a default for its items renders after them, so it cannot hand the default over as a prop — it patches the rendered items instead. Two free functions in rinch_core::dom let it do that again when the items change afterwards:

FunctionTold aboutHanded
on_child_inserted(root, f)a subtree landing anywhere beneath root (issue #716)the node that landed
on_child_removed(root, f)a subtree leaving anywhere beneath root (issue #745)the node it left — its former parent

Both call every registered ancestor, nearest first, synchronously with the mutation, so a patch is in place before the frame that shows the change is laid out. Both are released by the ambient scope’s on_cleanup and by discard() on the container, and dispatch is suppressed for the duration of a callback so a container’s own edits do not call it back. The scope’s cleanup releases the registration it made and no other: one that replaced it since (a second call on the node, or another container on a node id rinch-dom re-issued) stays (issue #1490).

The removal half is handed the parent rather than the node that went because that node is detached by then, and after a discard() the backend may have retired it — there is nothing left to walk. A move fires both halves, unless it is a reorder inside one parent, which fires only the insertion.

NodeHandle::set_text on an element that has children, and NodeHandle::set_inner_html, replace the node’s whole child list: each fires the removal half once, for that node (issue #1440), and neither fires the insertion half for the text or the parsed markup. set_text only detaches the children; the ones the element’s own render built — minted by the scope that minted the element or one descended from it — are discarded when that scope goes, if they are still detached then (issue #1487). Until then a handle to one re-inserts. UpdateBatch::apply calls the backend with the document already borrowed and fires neither half, so a DomUpdate::SetText should target a text node.

crates/rinch-components/src/late_children.rs wraps both with the boundary rule a nested container of the same kind needs.

DomDocument Trait

DomDocument is the trait that abstracts DOM operations. The primary desktop implementation is RinchDocument which uses Taffy + Parley + Vello. The web implementation is WebDocument which uses browser-native DOM via web_sys.

#![allow(unused)]
fn main() {
pub trait DomDocument {
    /// Create an element node
    fn create_element(&mut self, tag: &str) -> NodeId;

    /// Create a text node
    fn create_text(&mut self, content: &str) -> NodeId;

    /// Create a comment node
    fn create_comment(&mut self, content: &str) -> NodeId;

    /// Set text content of a node
    fn set_text_content(&mut self, node: NodeId, content: &str);

    /// Set an attribute
    fn set_attribute(&mut self, node: NodeId, name: &str, value: &str);

    /// Remove an attribute
    fn remove_attribute(&mut self, node: NodeId, name: &str);

    /// Append a child to a parent
    fn append_child(&mut self, parent: NodeId, child: NodeId);

    /// Insert a node before a reference node
    fn insert_before(&mut self, parent: NodeId, node: NodeId, reference: NodeId);

    /// Remove a node from its parent
    fn remove_child(&mut self, parent: NodeId, child: NodeId);

    /// Get children of a node
    fn get_children(&self, node: NodeId) -> Vec<NodeId>;

    /// Get the body element
    fn body(&self) -> NodeId;

    /// Mark a node as needing re-layout
    fn mark_dirty(&mut self, node: NodeId);
}
}

RinchDocument

RinchDocument is the desktop implementation of DomDocument that uses Taffy for layout, Parley for text, and Vello for rendering.

Key Features

  • Direct DOM manipulation - Efficient node creation and mutation
  • Automatic dirty marking - Calls mark_ancestors_dirty() after mutations
  • Event handler storage - Stores handlers as data-rid attributes for dispatch

Usage

#![allow(unused)]
fn main() {
use rinch_dom::RinchDocument;

// Create document
let doc = Rc::new(RefCell::new(RinchDocument::new()));

// Create RenderScope from shared document
let mut scope = RenderScope::new(Rc::downgrade(&doc) as _, parent_id);

// Build DOM
let root = my_app(&mut scope);
}

Thread Safety

RinchDocument is wrapped in Rc<RefCell<>> for interior mutability.

Effects capture clones of the shared document and can mutate the DOM when they run.

RenderScope itself holds a Weak<RefCell<dyn DomDocument>> to avoid preventing cleanup.

Integration with Reactive System

The RenderScope and NodeHandle APIs integrate with the reactive system:

  1. Initial render - Component function receives RenderScope (via __scope or #[component] macro), builds DOM tree
  2. Effect creation - __scope.create_effect() registers reactive computations
  3. NodeHandle capture - Effects capture NodeHandle clones for later updates
  4. Signal changes - Effects re-run and use NodeHandle methods to update DOM
  5. Cleanup - When scope is dropped, Effects are disposed

This architecture ensures that:

  • Components run once (no re-render overhead)
  • Updates are surgical (only affected nodes change)
  • Cleanup is automatic (scope disposal cleans up Effects)
  • Memory is efficient (NodeHandle is a lightweight ID + weak reference wrapper)

Reactive System Architecture

Rinch uses a fine-grained reactivity model inspired by Solid.js and Leptos. This document describes the architecture and design decisions.

Core Concepts

Signals

A Signal is a reactive container that holds a value and notifies subscribers when it changes.

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

// Read the value
let value = count.get();

// Update the value (triggers subscribers)
count.set(5);

// Update based on current value
count.update(|n| *n += 1);
}

Key properties:

  • Reading a signal inside an effect automatically subscribes to it
  • Setting a signal schedules dependent effects to re-run
  • Signals are Clone and can be shared across closures

Effects

An Effect is a side-effect that re-runs when its dependencies change.

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

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

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

Key properties:

  • Dependencies are tracked automatically (no dependency arrays)
  • Effects run immediately when created, then re-run when dependencies change
  • Effects are cleaned up when their scope is disposed

Memos

A Memo is a cached computed value that only recomputes when dependencies change.

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

doubled.get(); // Returns 4
count.set(3);
doubled.get(); // Returns 6 (recomputed)
doubled.get(); // Returns 6 (cached)
}

Key properties:

  • Lazily evaluated (only computes when read)
  • Caches the result until dependencies change
  • Can be read inside effects (creates a subscription)

Dependency Tracking

Rinch uses automatic dependency tracking at runtime:

  1. When an effect runs, it registers itself as the “current observer”
  2. When a signal is read, it checks for a current observer and subscribes it
  3. When a signal changes, it notifies all subscribers to re-run
┌─────────────┐     read      ┌─────────────┐
│   Effect    │ ───────────── │   Signal    │
│             │               │             │
│  observer   │ ◄──subscribe──│ subscribers │
└─────────────┘               └─────────────┘
                                    │
                                    │ set()
                                    ▼
                              notify observers

Runtime Architecture

┌────────────────────────────────────────────────────┐
│                    Runtime                          │
├────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐               │
│  │ Observer     │  │ Pending      │               │
│  │ Stack        │  │ Effects      │               │
│  └──────────────┘  └──────────────┘               │
│                                                    │
│  ┌──────────────────────────────────────────────┐ │
│  │              Signal Storage                   │ │
│  │  ┌────────┐ ┌────────┐ ┌────────┐           │ │
│  │  │Signal 1│ │Signal 2│ │Signal 3│  ...      │ │
│  │  └────────┘ └────────┘ └────────┘           │ │
│  └──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘

Observer Stack

The runtime maintains a stack of “current observers”:

  • When an effect starts, it pushes itself onto the stack
  • When a signal is read, it subscribes the top of the stack
  • When an effect ends, it pops itself

This allows nested effects to work correctly.

Batching

Multiple signal updates can be batched to avoid redundant effect runs:

#![allow(unused)]
fn main() {
batch(|| {
    count.set(1);
    name.set("Alice");
    // Effects only run once, after the batch
});
}

Scheduling

Effects are scheduled to run after the current synchronous code completes:

  1. Signal is set → effect is marked as “dirty”
  2. Dirty effects are queued for execution
  3. After current execution, queued effects run
  4. This prevents infinite loops and ensures consistent state

Memory Management

Scopes

A Scope manages the lifetime of reactive primitives. In practice, RenderScope serves as the scope for component rendering:

#![allow(unused)]
fn main() {
// RenderScope is the scope used during rendering.
// Effects created via __scope.create_effect() are tracked
// and disposed when the scope is dropped.

fn my_component(__scope: &mut RenderScope) -> NodeHandle {
    let signal = Signal::new(0);
    __scope.create_effect(|| { /* tracked by this scope */ });
    // signal and effect belong to this scope
    rsx! { div { } }
}
// When __scope is disposed, all its effects are cleaned up
}

Note: RenderScope is what application code sees — it tracks effects and child scopes for automatic cleanup. Underneath, each RenderScope owns a reactive Scope, and Scope::run(f) makes that scope the ambient owner for f: signals, memos, effects and event handlers created inside are attributed to it. Disposing the scope frees everything it owns — attribution is acted on, not merely recorded (issue #141). Disposal is a priority fixpoint rather than a linear pass: handlers, then effects, then cleanups, then memos, then signals, then the values themselves — restarting from the top whenever a step enqueues more work, because dropping a value can own the only handle to a child scope.

Two independent stacks are in play, and they are easy to confuse: untracked suspends the observer stack (who subscribes to a read), while unowned suspends the owner stack (who owns an allocation). With no ambient owner, a resource has app lifetime — which is why signals created in main() or in startup code keep working untouched.

Ownership

  • Signal<T> and Memo<T> are Copy handles, not smart pointers. Each is a (index, generation) pair into a thread-local slab; the value itself lives in the slab as a type-erased Box<dyn Any>. Freeing a slot bumps nothing but the free list — the generation is what makes a stale handle detectable, since a recycled index carries a generation no old handle holds.
  • Effects live in a registry keyed by ObserverId, not in the Effect handle. An effect is queued and run by id, so its closure has to outlive the handle; disposal is what removes the entry, and it removes it rather than emptying a slot that would stay in the container forever.
  • Disposing a scope frees every primitive it owns, and the Rcs are always dropped after the registry borrow is released — an effect closure commonly captures the only handle to a child scope, so dropping it in place would re-enter the registry that is still mutably borrowed.

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 implementation is deliberately single-threaded, and stores state in thread-local slabs rather than in shared smart pointers:

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

Layout Stage

RinchDocument::resolve_layout is the whole of it, and it has three paths rather than one. Which it takes is decided by two flags on the tree, and the flags are the part worth knowing, because a change that dirties neither is a change the pass will not see.

flagset bywhat it buys
layout_dirtya structural mutation, a text-content change, a viewport change, a decoded image, a Taffy style that actually changed, and a restyle that changes how text measuresthe Taffy compute
ifc_dirtya structural mutation, a display/position change, a DisplayMode change, a decoded <img>, a full restylethe inline-formatting-context setup passes, including the measure of every atomic inline
  • Neither dirty — styles are resolved, dirty Parley layouts are rebuilt, and the pass returns. A :hover { color } costs this and nothing more; it is the reason the early return exists.
  • layout_dirty only — Taffy runs over the existing IFC structure. Text measure contexts are refreshed incrementally, and the atomic inlines something changed under are re-measured from a dirty set.
  • Both — the IFC structure is rebuilt from scratch and every atomic inline in the document is measured.

A decoded <img> is on the ifc_dirty list for a reason worth knowing: it is how a newly loaded image reaches a detached inline-block box, which no other invalidation in the table would have reached.

Why a typography change is a layout change

font-family, font-weight, font-style, line-height, letter-spacing, word-spacing, text-transform, white-space and overflow-wrap are not Taffy properties, and neither is font-size for a box whose own sizes are in px. A declaration change in any of them re-wraps the text and therefore moves the box around it, so it sets layout_dirty even though no Taffy field changed (ComputedStyle::same_measured_text_inputs, issue #678). That predicate is deliberately narrower than the one that decides whether the shaped glyphs have to be rebuilt (same_text_layout_inputs, issue #654): color is baked into the glyphs and moves nothing, and keeping it out of the first list is what keeps the cheap path cheap.

A transition or an animation writes computed_style directly rather than through the cascade, so a transition: font-size frame reaches none of that gating on its own; both ticks invalidate the text measure of the nodes they are interpolating.

A color frame needs no new layout at all, and gets none (issue #679). The colour of each run is a brush inside the shaped layout, so paint asks every text range what colour its element computes now and draws the ranges that answer differently from the build in the new colour (paint::text::LiveColours); an underline follows where it was currentcolor, and a wavy underline and an inline element’s background are read from their element’s style on every paint. The layout is rebuilt once, with no Taffy compute, on the frame the colour stops moving, so a finished fade costs what a colour-only hover costs and later frames recolour nothing (each paint still compares every range’s colour with the one it was shaped in). A span on a line cut by text-overflow: ellipsis is the exception: that line is a flat rebuild with no text ranges, so the span keeps its start colour until the run ends (#1451).

Why an atomic inline needs its own pass

An inline-block, inline-flex or inline-grid box is detached from its parent’s Taffy child list so the enclosing inline formatting context can measure it as a Parley InlineBox. The root compute therefore never reaches it, and three passes give one a size, all through measure_inline_blocks: the ifc_dirty pass, which measures every atomic inline in the document; since issue #661, a re-measure of the boxes a change actually reached (dirty_atomic_inlines); and resolve_percentage_inline_blocks, which runs after the root compute and is what lets a width that needs the containing block’s — a percentage, fit-content/stretch, or an auto width, which is shrink-to-fit and so capped at that block (#658) — resolve against a containing block that only has a width once the compute has run. The inline formatting context around such a box is measured before that, and asked about widths the box is never laid out at; a measure lines an auto-width box up at what it contributes to the width asked about (ifc::AtomicContributions, #1476): its max-content size, its min-content size, or the shrink-to-fit of the two. That is what lets a container sized from its content — a flex item, a grid auto track, a shrink-to-fit absolute box, another auto atomic inline — come out narrower than the box, so the cap can bind. The min-content size is measured only when a line narrower than the box asks for it, after the compute that asked (a measure cannot start a compute), and the compute then runs again, followed by the pass once more: a pass that found such a request is looked at again after its compute (until no measure asks any more; ifc::MAX_INLINE_ROUNDS only guards against a cycle), and one that found none is followed by a single compute as before. A component that declares display: inline-flex — Badge, Button and the rest of the list in CLAUDE.md — is one of these whenever it sits beside text rather than inside a Stack.

Why an absolute box can need a second compute

Taffy lays an out-of-flow box out against its direct parent. CSS lays a position: absolute box out against its containing block: the padding box of the nearest positioned or transformed ancestor, or the viewport when there is none. crates/rinch-dom/src/out_of_flow.rs closes the gap in two halves.

The size is baked into the box’s Taffy style before the compute, so the box’s own children — a percentage child, wrapping content — are laid out inside the right box. For the viewport that is known up front. For an ancestor it is not: the ancestor’s used size is what the compute produces. So a style site bakes from the ancestor’s last laid-out size, and after each root compute resolve_ancestor_absolutes compares every absolute box whose containing block is a non-parent ancestor with the size that ancestor now has, rewrites the ones that differ and asks for another compute. An out-of-flow box sizes nothing above it, so one extra compute settles it (one more per level of nesting, with calc()’s fixpoint and its cap of 8). A box whose size does not depend on its containing block is never rewritten. The counter is abs_containing_block_passes.

The position is written as a parent-relative value, so LayoutResult means the same thing for every node and paint, stacking, hit testing and the debug protocol need no special case: the containing block’s border, plus the inset and margin, minus the offsets of the boxes between — plus those boxes’ scroll offsets, because a scroller between a box and its containing block does not carry the box. A scroll runs no layout, so NodeTree::mark_scrolled writes those positions again.

None of this touches a box Taffy already resolves — an absolute child of its own positioned parent, the usual badge or close button. The size check iterates only the boxes under a non-parent ancestor, a scroll re-places only when the scrolled box lies between a corrected box and its containing block, and the abs_boxes_visited counter reads 0 for a page of ordinary badges. A box under a non-parent ancestor costs about 0.8k instructions per layout (the size compare and the placement).

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 push_isolated_layer(&mut self, opacity: f32, ...); // never elided, even at opacity 1
    fn pop_layer(&mut self);
    // ...
}
}

Each text run casts its own element’s text-shadow (#1048). The property is inherited, so an IFC’s text casts its root’s list unless an inline element on the way down declared another (none included). When every run casts the root’s list the text is drawn exactly as one list; otherwise each distinct list is drawn through a mask that keeps it to the text casting it (paint::text::ShadowGroup, the #829 visibility mask’s per-range rule), every shadow before any text, as for one list. Chrome 153 paints shadow-then-text per text node instead: in <p style="text-shadow: -29px 0 blue">A<span>B</span></p> the shadow of B covers A there (measured) and not in rinch, whether or not the span declares its own list. Such a list is ink of the root that draws it: damage, the paint prune, the painted state and layer_bounds all count it. Two limits. In a nowrap/pre root that text-overflow: ellipsis cuts, every run casts the root’s list, because that layout records no text ranges, the same gap as #853 (#1065); a wrapping root with such a span is left un-cut. And each distinct list is a pass over the lines its text is on, so a paragraph of many spans with different lists costs about lists × parley’s per-line item walk: 193 ms for 400 distinct lists in one paragraph, against 4.6 ms for one shared list (#1066).

A blurred text-shadow is a blurred mask (#980, paint/text_shadow.rs). Vello has no general blur, so wherever the software rasteriser is compiled in (every desktop and Android build) the shadow — glyphs, underline, line-through and wavy underline (#981) — is rasterised once into a coverage mask over the part of it that can be seen, blurred by a separable Gaussian of standard deviation blur / 2 out to three of them, and handed to the painter as Painter::draw_alpha_mask: the same mask to Vello (as an image) and to tiny-skia (a solid fill through a pooled surface mask). Blurred masks are kept between paints (32 MB, least recently used out), keyed by a hash of every call that rasterised them in the terms that decide its pixels: each glyph by the pixel the software painter rounds it to, a decoration to a quarter pixel. The mask is always rasterised at the text’s true position. A repaint of a shadow that is wholly on screen and unchanged, or has moved by whole pixels, or by a fraction that moves none of its glyphs to another pixel, rasterises nothing; a shadow the surface, a clip or the damage crops is rasterised again whenever the crop moves relative to it (a line scrolling across a scroller’s edge), and so is any shadow whose text or style changed. text_shadow_masks_rasterised counts what a paint rasterised. The Vello-only build (embed without software-renderer) has no rasteriser and draws a kernel of copies in BlendMode::Plus layers inside one push_isolated_layer: 13 taps, a 5-tap cross or none, chosen by the shadow’s own glyph count against TAP_GLYPHS_PER_SHADOW, with TAP_GLYPH_BUDGET as a per-paint crash guard. Cost: dom::text_shadow_paint in the Perf job, 40 paragraphs of 14px text at 800x600 with 0 1px 4px — paragraphs_40 repaints from the cache (61M instructions in CI), paragraphs_40_cold rasterises every visible mask.

Application code never interacts with the Painter directly. Cargo features decide which backends a build carries, and a gpu build picks one of its two when the window opens (see below).

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, carried by every desktop build: the only backend without gpu, and the fallback with it:

  • 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, or outside the window, are skipped
  • Works in headless environments, CI, containers, SSH sessions

Rendering Backends

Choosing a Backend

Your Cargo.toml decides which backends the build carries:

# GPU, with software as the fallback (recommended for most apps):
rinch = { workspace = true, features = ["desktop", "gpu"] }

# Software only (no GPU required, smaller build):
rinch = { workspace = true, features = ["desktop"] }

A gpu build then chooses when the window opens (crates/rinch/src/shell/renderer.rs): App::renderer(Renderer::Auto), the default, tries the GPU and presents with software when it will not start; Renderer::Gpu panics instead; Renderer::Software skips the GPU. The RINCH_RENDERER environment variable (auto, gpu, software, cpu) overrides the app’s choice. An app that configured the GPU device itself (gpu_config / external_gpu) always presents on the GPU. The details are in Rendering Backends.

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.

Every dirty node contributes two rects: its current box, grown by how far its own outset box-shadow, outline and text-shadow reach (an inset shadow paints inside its padding box), and the rect it was last painted in. The second is summed up the box-tree chain from what each node was last painted with — Node::prev_layout and Node::painted (own ink reach, own transform), parent-relative, written only by the paint that consumes the region (NodeTree::consume_paint_dirty: the software frame, a full repaint and a GPU scene build alike). So the old pixels are found however many layout passes ran between two paints, when an ancestor moved rather than the node, and when a box moved and dropped its shadow, lost a child or changed its transform in the same frame; a removal records the painted rect of every node it takes out. A positioned box (absolute, fixed or relative) that moved is also grown by its whole painted subtree’s reach, which carries an untouched overflowing child with it. That is what lets an editor keystroke, a caret move or a dragged absolute panel repaint a region instead of the whole window. Once the region reaches half the surface (FULL_REPAINT_FRACTION) it stops being measured and the frame repaints in full.

The region is the union of the changed nodes’ rects, so anything painted outside the node tree has to contribute its own. The drag ghost is the one such overlay: it is blitted into the framebuffer after the document paint, so RinchApp remembers the rect it covered and folds that into the next frame’s dirty region — otherwise the frame that stops drawing the ghost would never clear where it had been, leaving it stuck on screen (issue #173). The GPU backend rebuilds the whole Vello scene every dirty frame and so has no equivalent case.

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.

The frame clock

Every shell must send PlatformEvent::AboutToWait once per iteration of its event loop. It is not an optimisation and it is not optional: it is the only place RinchApp advances CSS transitions and CSS animations, marks the scene dirty when either moved, resolves the dirty state the input handlers deliberately leave for it to batch, and drains the focus requests effects raise. The winit shell sends it from ActiveEventLoop::about_to_wait; the Android shell sends it from android_frame::pump_frame, once per 16ms poll; embed::RinchContext sends it from update.

A shell that omits it does not look broken. Every screen still paints and every un-animated control still works — but a transitioned property is sampled once, at the instant the transition starts, which is its old value, and stays there for ever, while un-transitioned properties on the same element apply immediately. A bottom sheet then answers a tap by becoming pointer-active without moving: open, invisible, and covering the screen.

Turning the clock is only half of it. A transition on a paint-only property — opacity, transform, a colour — marks its node PAINT-dirty and nothing else, so has_pending_layout() stays false and no RequestRedraw is raised. A shell decides whether to present from RinchApp::scene_dirty, which AboutToWait sets whenever a transition or an animation moved; a shell that does not consult it turns the clock in private, and the surface keeps the frame from before the change until something unrelated forces a present.

And scene_dirty is set for every tick that had something to tick, not only for ticks after which something is still running. A transition that finishes on a tick applies its end value and then reports nothing active — and on a shell whose first paint after the change is slower than the transition is long (Android’s is around 300ms against a typical 220ms), that is the only tick the transition ever gets.

Starting a transition

Turning the clock is what advances a transition; style resolution is what starts, retargets, reverses or cancels one. transition::start_transitions implements css-transitions-1 §3, “Starting of transitions”, against the properties the style differ found a change in:

The running transitionWhat happens
nonestart from the before-change value over the declared duration (§3 item 1)
its end value still equals the after-change valueleft exactly as it is — same endpoints, same clock
it has already reached the after-change valuecancelled; nothing is started (§3 item 4.1)
the combined duration (duration + delay) is zero or lesscancelled; nothing is started (§3 item 4.2)
the after-change value is the value it would reverse back tocancelled, and a shortened reversal started (§3 item 4.3)
anything elsecancelled and restarted from the current interpolated value, over the full declared duration (§3 item 4.4)

The second row is the one that is easy to get wrong, because the caller diffs the node’s computed_style — which holds the interpolated value while a transition runs — against the freshly resolved target. Every restyle of a transitioning node therefore looks like a change, and rinch used to restart the transition on each one with a brand new clock (#652). A declared 150ms animation then ran for as long as restyles kept arriving, its duration a function of how much else on the page happened to be animating; with ease, which is slow near t=0, each restart advanced the value by a sliver and the box crawled toward its target instead of arriving.

A property whose transition was left alone is still reported as transitioning to the caller. That is what tells style resolution to write the interpolated value back over the after-change style it has just assigned wholesale — without it the box would snap to its end value for one frame, which is all #489 needed.

A reversal is shortened. A reversal taken when the transition is half way there is half as much travel back, so it gets half the duration and lands on its old start value at the moment the cancelled transition would have reached its end value — the behaviour a hover in and straight back out depends on. The factor folds the running transition’s own factor back in (|f·progress + (1 − f)|), so reversing a reversal is measured against the declared duration rather than compounding toward zero.

Note that it keys on progress, not on elapsed time. Under linear the two coincide, so reversing a 150ms transition after 75ms takes 75ms. Under ease — which is what every transition in rinch-components declares, and linear what none of them do — the output at input 0.5 is 0.8024, so the same reversal takes 120.4ms. It has to: 80% of the distance has been covered and 80% of it has to be covered again.

The delay is not shortened with it, unless it is negative. A negative transition-delay is an offset into the curve, so a shortened curve is entered proportionally further along; a nonnegative one is a wait before the curve begins, and the spec uses it as declared. HoverCard is what this protects: its close direction carries transition-delay: 150ms as a grace period for moving the pointer onto the card, and scaling that with the factor would halve the grace period for anyone who leaves part way through the fade-in.

A transform is interpolated as a function list, not matrix entry by entry (#414). A computed transform keeps the list it was composed from (TransformValue::functions), and transition::transform::interpolate_lists interpolates it the way Chrome does: functions that pair by type interpolate their arguments (rotate(0deg) → rotate(90deg) is rotate(45deg) half way, full size), a shorter list — none included — is padded with identity functions, and from the first pair that does not match, the rest of both lists is composed and interpolated by Chromium’s 2D decomposition (translation, scale, shear and the shorter way round for the angle; a singular matrix flips half way). Lerping the six matrix entries, which is what rinch did before, gives the right angle with a uniform scale of cos(Δθ/2): a 90° turn shrank to 71% at its midpoint and a 180° one to a point. Two consequences: the interpolated value is itself a function list, so a reversal or a retarget goes on pairing by function; and “did the transform change” compares the lists, so rotate(0deg) → rotate(360deg) is a change and spins, while none → rotate(0deg) is not (Chrome starts an invisible transition there; rinch runs no frames for it, since rotate(0deg) at rest is already the stacking context a running transition would be — #415). @keyframes stops go through the same code. tests/transform_interpolation_tests.rs pins it against Chrome 153’s getComputedStyle numbers.

The 3D transform functions are drawn flat (#405). rinch paints in 2D, and every 3D function but translate3d used to compute to the identity — so rotateZ(45deg), the same rotation as rotate(45deg), did not rotate. Now a list holding rotateX/rotateY/rotateZ/rotate3d, scaleZ/scale3d, translateZ, matrix3d or perspective() is composed as 4×4 matrices and the result is projected the way Chrome draws an element with no perspective or preserve-3d ancestor: applied to the plane z = 0, divided by w, the resulting z dropped. So rotateX(60deg) squashes the box to half its height, and perspective(100px) translateZ(10px) magnifies it by 100/90. A plane at or behind the viewer (w <= 0, perspective(10px) translateZ(20px)) is neither drawn nor hit, as in Chrome — it flattens to the zero matrix scale(0) gives. So is a box whose 4×4 is singular (scaleZ(0), scale3d(1, 1, 0), a matrix3d() with a zero column), although its flattening may be invertible — scaleZ(0) flattens to the identity — and its subtree with it, a position: fixed descendant aside, as for a hidden backface below (#1051). The cut is where Chrome 153’s is, measured: a determinant that is not a normal f32, so scaleZ(1e-20) is still drawn and scaleZ(1e-38) is not. (Chrome’s getBoundingClientRect() still reports the flattened rect for such a box.) Only the composed matrix is flattened — rotateX(90deg) scaleY(2) rotateX(-90deg) is the identity, where flattening each function would draw a line. For interpolation every rotate* is one primitive (a common axis lerps the angle, two axes slerp a quaternion the shorter way), perspective(d) interpolates 1/d, and matrix3d() pairs with matrix3d() but not with matrix(). tests/transform_3d_tests.rs pins all of it against Chrome 153. What is not Chrome’s: the perspective and transform-style properties are not implemented at all, so a parent’s perspective does nothing; a transform whose w depends on x or y (perspective(200px) rotateY(30deg), a trapezoid in Chrome) is drawn with those terms dropped, since no affine draws a trapezoid; a mismatched remainder holding a 3D function is decomposed as a flattened 2D matrix, where Chrome decomposes the 4×4 (#989).

transform-origin’s z component is applied before the flattening — a list is composed as M · T(0, 0, -z) and flattened, and paint applies the x and y about the flattened matrix as before — so rotateY(45deg) about 50% 50% 100px lands where Chrome’s does. backface-visibility: hidden hides a box whose own transform turns its back to the viewer (the (3, 3) entry of the inverse 4×4 is negative, Chrome’s test): it composes to the zero matrix, so it and its whole subtree are neither drawn nor hit — except a position: fixed descendant, which rinch does not contain in a transformed ancestor (#1372), so it keeps the body’s transform and is still drawn. A mirror (scaleX(-1)) is not a turn, and a child under a turned parent is judged by its own transform alone, as in Chrome without transform-style: preserve-3d (#997).

One thing rinch does not implement from §3: the transitionability precondition, which appears in item 1 and again in item 4.2: a pair of values that cannot be interpolated — a length against a percentage, which needs a calc() that ComputedStyle cannot hold — still gets an ActiveTransition, which then idles for its whole duration because AnimatableValue::interpolate answers None for it. The property snaps either way; cancelling instead would only save the idle ticks.

Item 3 is implemented (issue #693): a running transition whose property has stopped matching transition-property is cancelled on the next cascade of its node, whether or not the property also changed on that restyle, and the after-change value stands. It used to be left running, so the box snapped to the target on the restyle and jumped backwards on the next tick. It is also what makes a delayed visibility hide safe to reopen through (see Components).

The before-change style, and who has one

Everything above assumes there is a before-change style to leave. §3 only runs for a node that has one, and in rinch that is one bool on the node: has_been_styled, set by apply_stylo_styles_to_taffy the first time the node is cascaded. With it clear, the resolved style is assigned wholesale and no transition is considered — which is what makes a freshly mounted element appear at its final size instead of animating to it.

Two rules keep that flag honest, and they are the same rule read from the two ends:

  • A node outside the document is never styled, so it never acquires the flag (#651/#668). resolve_styles drops a style_roots entry whose node is not connected to tree.root_id.
  • A subtree that leaves the document loses the flag, and any transition or animation running on it is cancelled (#699). RinchDocument::detach_subtree_styles does all three, for the whole removed subtree. Five places in dom_impl/dom_document_impl.rs write parent = None and four call it: remove_node, remove_child, replace_node’s displaced old, and set_text_content on an element with children, which orphans every one of them without freeing the slab. The fifth, set_inner_html, is safe by destruction — NodeTree::remove_subtree frees the entries and drops both animation maps with them.

Without the second, a node styled while it was connected, then detached, keeps the flag and the computed_style it had in the document. If an ancestor’s class changes while it is out, its re-insertion resolves to a different value, §3 sees old ≠ new on an already-styled node, and the box animates in from a style the user never saw. A browser does not: a removed element is not rendered, it has no before-change style, and re-insertion is a first style.

What the detach does not touch is the computed_style itself, or the shaped text_layout. A detached node still reads back as it last did in the document — dom_tree(root_id: <a detached id>) depends on that, and so do the staleness gates that decide whether a re-inserted subtree needs re-shaping. The flag is what the transition reads; the value is what everything else reads.

A move is not a detach. append_child, insert_before and insert_child unlink a node from its old parent with the same lines remove_child uses, but the node is back in the document before the call returns, so it never stopped being rendered and a mid-flight transition goes on running — which is what a keyed for reorder depends on, since it moves rows with insert_after. A move within one parent is not re-cascaded either (#914): its ancestor chain is the one it was styled against, and the selector flags on the parent restyle it when a positional selector (:nth-child, +, ~, …) can see the move. Nor does it drop its own shaped text — only the inline formatting context it was a member of, if any. A move to another parent re-cascades the subtree.

Unless the destination is itself detached (#702). A mounted node moved into a parent that is not connected to tree.root_id has left the document while keeping a parent, so it fails the parent = None test the four detach routes share — and #696 established that connectivity, not the parent field, is the question that matters. Splice that parent in somewhere else later and the node animates in from its pre-move style, which is #699’s symptom by a fifth route. detach_subtree_styles_if_moved_out is the answer, at four sites: the three move verbs and replace_node, which splices its incoming new into old’s parent and is a move out whenever that parent is detached.

Two guards decide, and the order matters. The destination must differ from the old parent — a move within one container cannot change whether the child is connected, because the child’s reachability is its parent’s — and the child must already have a parent, since a node created moments ago cannot be a move. Only a reparenting move reaches depth_if_connected, the same O(depth) walk #696 filters style_roots with.

Counted, on 500 of each shape:

workloadhelperwalksresetsnodes reset
rows built straight into their final parent0000
keyed reorder, each row to the front499000
rows reparented into a second connected list50050000
rsx! component sites, 20 nodes each50050050010,000

The keyed reorder is the hot path and it never walks. The last row is the one to know about: rsx! does not build a component site by appending fresh nodes into their final parent. component_codegen puts a site’s children into a <template> attached to nothing (#719) and Component::render adopts them into a root that is also still detached, so every adoption is a move into a detached parent — it walks, and it takes the whole subtree reset. The reset is semantically a no-op there (a node created moments ago is already unstyled, with empty transition and animation maps) and the cost does not show: best of 40, release, three alternated rounds, the build with the helper was the faster of the two every time. Say “a node appended straight into its final parent pays nothing”, not “building a tree pays nothing”.

One behaviour change follows, and it is narrower than it looks. A node that is mounted and still connected when it is moved into a detached parent, and adopted straight back out in the same pass, loses its running transitions and restarts its animations — a browser never sees that intermediate state, because its style recalc is batched to the end of the task. The component re-render path does not reach it (reactive_component_dom removes the old output first, so #699 has already reset that subtree); handing a component a handle that is mounted elsewhere and still connected does.

display: none is not a detach either, and it does not need to be. §3’s question is not is this node in the document but is it being rendered, and a hidden node is not — nor is anything inside one, since display does not inherit and a box under a hidden wrapper computes display: block. So the flag is only half the answer, and the cascade asks the other half directly (#703): before starting a transition, is_rendered_for_transition checks the node’s display before the change as well as after, then walks its ancestors. A style change made while an element is hidden lands on it outright, the way a browser applies one, so the element is already at its new value when it is shown. In the other direction, an element that stops being rendered has its transitions cancelled, and so does everything under it — a descendant’s own cascade need not run at all when an ancestor is hidden.

Three things about that are worth knowing.

  • It reads the old display, not just the new one. A single class write can un-hide a box and retarget it at once. At that cascade the new display is already block; only the old one says there was nothing to transition from.
  • was_hidden exists because parents cascade first. An ancestor restyled on the same pass is carrying its new display by the time a descendant is reached, so the pass records the nodes it found hidden as it goes.
  • visibility: hidden is rendered. The box is generated, laid out and takes up space; it has a before-change style and its transitions run. Folding it in here would be wrong, and invisible in every display fixture.

The walk is O(depth) and sits at the last gate before a transition starts — after diff_animatable has found an animatable change on a node that declares a transition — so a node with neither never pays for it.

This caught a shipped component, and the rule it puts on the library is worth stating on its own: an overlay that animates must stay rendered. Animate opacity, visibility or transform; never toggle display. A display flip cannot transition on either backend — a browser refuses it for the same reason, which is why @starting-style and transition-behavior: allow-discrete exist — and rinch-components ships one stylesheet to both.

The Drawer was the one component with it backwards (issue #751): its root carried display: none while closed and its panel carried transition: transform, and one reactive effect un-hid the root and retargeted the panel in a single pass, exactly the shape above. So its 300ms slide-in ran on desktop only until this change, and had never run on rinch-web at all. Its closed state is visibility: hidden now, which is where Popover already was — hidden, still rendered, and — once its close has run — out of paint, hit testing and the Tab order on both backends. Since issue #759 the close holds the root visible for the panel’s 300ms slide-out (a delayed visibility transition), so for those 300ms it still paints, takes clicks and is tabbable, as in a browser.

The blast radius is wider than “a control restyled in an inactive tab”: any component that un-hides an ancestor and retargets a transitioned property in the same style pass loses its animation. Modal, Notification, Tooltip, Select, DropdownMenu, Tabs and Stepper are audited one fixture each in crates/rinch/src/app/overlay_animation_audit_tests.rs, against the rule “a transitioned property that changes on the reveal pass must have a transition running” — which is the only form of the assertion that discriminates, since “zero transitions ran” is what the bug looks like.

The reactive helpers rest on this now, and no longer on a second mechanism of their own. show_dom, match_dom, for_each_dom_typed and the component re-render effect used to call NodeHandle::clear_animations() before remove(), which wrote an inline transition: none; animation: none over the whole subtree — and nothing ever took it off, so a branch hidden once could never animate again (issue #704). It also hid the detach reset: through one of those helpers, deleting detach_subtree_styles changed nothing observable. The method and all five call sites are gone. Every one of them was clear_animations(); remove(); and NodeHandle::remove is DomDocument::remove_node, the first of the five routes listed above; on rinch-web the browser already cancels a removed element’s transitions and treats re-insertion as a first style. A removal path must not write styles: the node outlives the removal, so anything stamped there is permanent.

The page-load guard arms transitions, and only transitions

has_been_styled is per node. There is a second, whole-document suppressor beside it: NodeTree::transitions_enabled, false at construction and set at the very end of the first resolve_layout (“Enable transitions after first layout completes”, layout_engine.rs). has_been_styled alone would not do the job, because a tree is cascaded more than once before its first layout — appending a <style> element re-resolves the whole document there and then (maybe_load_style_css), so a component that appends its own stylesheet after building its markup leaves every node already styled, and the next rule it loads is a change on an already-styled node. Without the flag that is a transition running on page load. recompute_all_styles_full forces the same flag off for the duration of its re-cascade, for the same kind of reason: a theme change applies instantly rather than every element transitioning from the old palette to the new one.

Neither rule is about animations, and since #762 neither reaches one. A @keyframes animation has no before-change style to be wrong about — it does not interpolate from a previous style, it plays its own — and a browser runs one on the very first frame the element exists. The animation half of apply_stylo_styles_to_taffy sat inside the same if as the transition half, and two faults followed:

  • An animation present in the first frame never started. Nothing re-cascades those nodes afterwards, so it was simply gone. On desktop the first resize or scale-factor event happens to re-cascade the tree and the spinner starts, which is why this went unnoticed; an embedded RinchContext at a fixed size gets no such event and kept a dead spinner for the life of the context.
  • A theme change stopped every animation in the document, permanently. recompute_all_styles_full cleared tree.active_animations and re-registered none, because the flag it had just forced off was the gate. Toggling dark mode killed every Loader, Skeleton, Progress stripe and spinner in the app.

So the animation block reads no flag, and recompute_all_styles_full no longer clears the map. The re-cascade it runs is what reconciles it: animation::start_animations matches a running animation by name, keeps its clock — start_time_ms, paused_elapsed_ms, play_state — and drops one whose declaration the new sheet no longer carries. On this pass, and only this one (NodeTree::refreshing_animations, a flag of its own), a kept animation takes everything else afresh: the @keyframes rule is looked up again and the animation dropped if it is gone, the stops are re-extracted from the new base style, and the duration and delay come from the new declaration.

Preserving rather than restarting is what a browser does, measured in Chrome 150.0.7871.100 by replacing a <style> element’s textContent under a running animation: … 10s linear infinite and reading Element.getAnimations():

The swapgetAnimations()currentTimeeffect
identical rules, one unrelated declaration changed1unchangedunchanged
same animation-name, changed @keyframes body1unchangednew keyframes, applied at once
@keyframes deleted, declaration kept0—back to the base style
animation declaration deleted0—back to the base style

rinch matches all four on the theme path. Rows 2 and 3 took the refresh, and row 3 is worth a sentence because an earlier revision of this page called it unreachable: the theme sheet is replaceable and can carry @keyframes — rinch’s own generate_theme_css_string puts component keyframes in it — so a new theme can drop one, and keeping the entry whole left that animation running where Chrome 153 cancels it. A later measurement in Chrome 153.0.8010.36, under a seeked currentTime, added three more rows the refresh also matches:

The swapChrome 153
animation-name k → k2a new animation, currentTime 0
animation-duration 10s → 20ssame animation, currentTime 3000, progress 0.15
a panel shown by the theme, which also moves font-size 10px → 40px under a 1em → 11em spinnerwidth 80px at 1000ms

The refresh is scoped to recompute_all_styles_full on purpose, and that is not the only pass that re-cascades the whole document. Two others drop every node’s cached style too and do not set the flag: appending a <style> element (maybe_load_style_css), and a viewport change in resolve_layout. Measured after the first layout, a <style> appended with a redefined @keyframes kk leaves a running kk on its old body. Those two passes, and every targeted restyle — a class change, a hover — still keep a kept animation’s stops and timing whole: an edited @keyframes body (issue #766), a changed duration or delay (#780), and a stop derived from the base style, such as the implicit from of a to-only rule carrying color (#781), stay stale. #781 tracks the two whole-document passes. The full restyle is the pass a theme toggle takes, it runs rarely, and it can afford a keyframes lookup and a stop extraction per animated node; a targeted restyle runs per hover and should not pay that. crates/rinch-dom/tests/full_restyle_animation_refresh_tests.rs pins every row.

One divergence the theme path now reaches, not fixed here: a finished one-shot animation replays on a theme toggle. An animation with a finite iteration count and no forwards fill is removed from tree.active_animations by tick_animations once it completes. The full restyle re-cascades its node, start_animations finds no entry of that name, and mints a new one from t=0. Chrome does not replay it, and neither did main before #762 — its full restyle cleared the map with the animation block gated off, so nothing was minted, which was right by accident. It is the mechanism of issue #783 (a finished animation’s entry is forgotten, so any later cascade of the node restarts it), reached through the theme path. Component CSS declares only infinite animations; the one finite iteration count rinch ships is the theme’s prefers-reduced-motion rule (animation-duration: 0.01ms; animation-iteration-count: 1), whose replay would last 0.01ms.

One consequence comes with the first-frame start, and it is pre-existing rather than new: an animation’s clock begins at the cascade that styles the node — append_child, for a freshly appended element — not at the frame that first shows it. So the first frame is already a few milliseconds in, where a browser defers an animation’s start to the first frame it is rendered in. Every post-mount insertion has always behaved that way; #762 only lets the first frame join them. Issue #768.

crates/rinch-dom/tests/animation_start_gating_tests.rs is the pin, and its module doc carries the measurement above plus a table of which fixture kills which mutant — including the two results that are easy to get wrong by reasoning: no animation fixture moves when the flag is removed from the transition gate, and “a transition declared before the first layout does not run” does not distinguish the flag from has_been_styled, because on a first cascade both suppress.

A hidden element’s @keyframes animations

@keyframes is the same question with a stricter answer and a second consumer, and it took its own change (issue #747).

The answer is stricter because css-animations-1 §3 does not merely refuse to start something on an element that is not being rendered: such an element has no animation effect at all, and showing it again starts a new animation from the beginning rather than resuming the one it had. So rinch drops the ActiveAnimation entries rather than parking them, and starts fresh ones on the way back.

The second consumer is the frame clock. A transition self-limits — it has a declared duration and dies after it — but animation: … infinite does not, and AboutToWait schedules another frame whenever tree.active_animations holds an animation that is not paused. An animation left running on something nobody paints therefore keeps a desktop app rendering at full rate indefinitely: a Loader in a closed panel or an inactive tab, with nothing on screen moving. That is why parking the entries was not an option, and it is the same symptom #699 fixed for a removed Loader.

Three sites do it, all in apply_stylo_styles_to_taffy:

  • Nothing starts on a node that is not rendered. animation_is_rendered is the transition gate’s ancestor walk asked of the state after the cascade — “is it rendered now” — so it takes no old display and no was_hidden list. Reusing the transition gate here would be wrong in a way nothing else notices: was_hidden names the nodes that were hidden before the change, so an ancestor un-hidden on this same pass is on it, and an inner wrapper shown beneath it would refuse its own restart. The gate also takes away what the node was already running, which the subtree walks cannot: a node moved into a hidden panel changes nobody’s display, so the only thing that runs is its own re-cascade.
  • A subtree that stops being rendered loses its animations, whole, whatever each box’s own display computes to — cancel_animations_in_subtree, beside the transition cancel and under its own guard.
  • A subtree that starts being rendered again gets them back, from t=0 — restart_animations_in_subtree, at the same site that resets the scroll offset. This is the half that has no counterpart on the transition side, and it is not symmetry for its own sake: a node shown by an ancestor need not be re-cascaded at all. set_style drops the cached Stylo data of the node it was written to and of nothing else, so a panel un-hidden with set_style("display", "block") re-cascades the panel alone; without the walk its spinner would stop on the way in and never start again. A class write invalidates the subtree and so restarts descendants through the ordinary per-node path — the two routes differ, and the fixtures say which is which. The walk stops at any box whose own display is none, and skips the node it was called on, whose own cascade has already restarted it.

It costs O(subtree) on a change from none to rendered, which forces a full layout and paint of that same subtree anyway; there is deliberately no “does anything under here animate” guard, because there is no answer to that cheaper than the walk. Measured on the worst case that can be built for it — hiding and showing a 4000-node, animation-free panel, debug build — the walk costs +4.2% of the cycle (21.06 → 21.95ms).

visibility: hidden animates, and that is a frame clock nobody switches off

visibility is rendered here for exactly the reason it is rendered for a transition: the box is generated, laid out and takes up space. It is also what a browser does — a visibility: hidden element’s animation runs in Chrome, it is merely not painted.

The cost is worth stating plainly, because it is not the one-off a transition’s would be. An animation: … infinite has no duration to expire, so a rendered- but-invisible spinner asks for a frame forever. That is now reachable through a shipped component: #751 made the closed Drawer’s root visibility: hidden precisely so its panel could transition, so a Loader placed inside a closed drawer kept the app rendering until #912 paused it (below). Measured before that, software backend, 804x600, closed drawer, 20 idle frames:

closed-state spellingactive_animationsidle frames asking to redrawms per tick+paint
display: none00 / 200.001
visibility: hidden, running (the Drawer before #912)120 / 202.53
visibility: hidden, paused (the Drawer since #912)10 / 20—
open drawer, either spelling120 / 208.8 – 9.7

This is accepted rather than overlooked. Refusing an animation to a visibility: hidden box would put desktop at odds with both the browser and the transition rule next to it, for one component’s benefit. The cure belongs to the component — animation-play-state: paused on a closed overlay’s subtree — and since issue #763 it works. A paused animation keeps its entry, because its frozen sample still has to reach computed_style on each cascade, but it has nothing to advance: tick_animations neither counts it nor marks its node dirty, and AboutToWait’s “was there anything to tick” guard asks NodeTree::has_running_animations() instead of whether active_animations is empty. So a paused spinner schedules no frame, and resuming it continues from the time it was paused at. A paused font-size (or any other text-measure) animation is measured by each cascade that writes its sample, and never per tick.

That is the one cost this change adds, so it is stated with numbers: every cascade of a node carrying a text-measure animation re-measures its text and re-runs Taffy, whether or not the sample moved. Measured on 500 rows, release, min of 20 (hover) / 12 (restyle):

workloadno animationwith a paused font-size animation
one-row colour-only hover on that row0.143ms, 0 Taffy computes0.679ms, 1 compute
whole-document colour-only restyle16.1ms45.1ms (all 500 rows animated)

It is bounded by how many nodes carry such an animation, which is rare, and the behaviour it replaces paid a compute on every frame for the same node. The narrowing is available and deliberately not done here: compare the node’s old computed_style against the post-animation style, rather than asking only whether one of its animations has a font-size stop. That would make a hover over an unchanged paused sample free.

The invalidation is also the whole of the guarantee, which is why issue #784 mattered: compute_inline_block_layouts measures an atomic inline out of Taffy’s cache, so text inside an inline-block was re-measured by nothing when a none → rendered crossing changed it, and a paused sample stayed frozen in the old font for good. mark_atomic_inline_dirty now marks those Taffy nodes on an ifc_dirty pass instead of returning early.

A finished animation with animation-fill-mode: forwards or both is the same shape (issue #782): its fill is as constant as a paused sample. The tick that finishes it writes the fill and marks the node dirty — that frame shows the end — and records it in ActiveAnimation::fill_settled; later ticks re-apply it without dirtying anything, and neither tick_animations nor has_running_animations() counts it.

The component library declares the pause itself (issue #912). Drawer’s closed rule, and the closed states of Popover’s and HoverCard’s dropdowns — the three overlays that close with visibility: hidden and take content — pause every animation beneath them:

.rinch-drawer__root--hidden * { animation-play-state: paused !important; }
.rinch-popover__dropdown:not(.rinch-popover--opened .rinch-popover__dropdown) * {
    animation-play-state: paused !important;
}

Every descendant, not a list of known spinners, because any component or app rule can declare an animation. Not the pseudo-elements, although animation-play-state does not inherit into them, so an ::after spinner under a closed overlay still runs — on rinch-web, and since #1004 on desktop too, where a generated box carries its pseudo cascade and its animation starts (#925; desktop restarts it at every cascade of its originator, #1023), keeping the frame clock running. They are left out because rinch-dom matches ::before/::after rules with no ancestor bloom filter (#935), so *::before/*::after rules cost +10% of style instructions on every page that loads the component CSS and +71% under a closed drawer. !important because the animation shorthand resets animation-play-state to running: without it the pause loses to any shorthand of equal specificity declared later (.rinch-loader__oval) or higher (.rinch-button--loading .rinch-button__loader). An app that really wants an animation to run inside a closed overlay needs an !important of its own. The :not() is the exact complement of the rule that makes the dropdown visible. A closed drawer holding a Loader therefore idles, and opening it resumes the spinner where it stopped, as a browser keeps currentTime.

None of the three sites reads transitions_enabled (see “The page-load guard arms transitions, and only transitions” above), and for the restart walk that is a separate decision rather than a consequence. The walk shipped behind the flag, and the flag is off for every cascade before the first layout completes — so a panel shown by an inline display write on one of those passes dropped its descendants’ animations on the way out and did not start them again until something unrelated re-cascaded the subtree. animation_start_gating_tests.rs pins that shape twice, once for a spinner that never ran and once for one that did, whose clock must restart inside the show pass.

The other flag-off pass, recompute_all_styles_full, reaches the walk too, and an earlier revision of this page said it did not. A theme that un-hides a panel runs the walk on the panel’s cascade, before the descendants’ own, so the walk mints their entries from their pre-restyle computed_style — for an em-sized spinner under a theme that also changes the font-size, stops on the old basis (20px where Chrome gives 80px). Counting running animations cannot see it, because every node is re-cascaded on that pass and the spinner runs either way. What repairs it is the refresh above: each descendant’s own cascade comes after the walk and re-extracts the stops from its new style.

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
  • Viewport culling (both backends) - Skip nodes that fall outside the window. This is the dirty-region test against a second region that is always present, so it applies to a full repaint too. It matters most on the GPU: Vello discards invisible paths in its coarse stage, but only after flattening every one of them.
  • Clip elision (both backends) - A clip layer that provably removes no drawn pixel is not pushed. Two cases qualify: a clip whose rect contains the whole render target (it can only remove pixels that are discarded anyway) and a clip nothing inside reaches past. Both require square corners — a rounded clip cuts the corners of its own box, so “nothing overflows” does not mean “nothing is cut”.
  • 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 the frame clock (AboutToWait, above)
  • Layer compositing - GPU layers for transformed content (planned)
  • Text caching - Glyph atlas for repeated text (planned)

Two things the first two do not do, because both would be visible bugs rather than optimisations.

Neither an ancestor’s culling nor its elision can remove a position: fixed descendant. A fixed box is painted in viewport space, from the sequence of its nearest stacking-context ancestor, so that ancestor’s box says nothing about whether the fixed box is on screen — an off-window stacking context holding one is therefore not pruned, and a clip whose subtree holds a fixed box is never elided. Note the shape of that claim: it is about what an ancestor may conclude, not about the fixed box itself. A fixed box is culled and elided on its own terms like any other — one covering the window has its own clip elided, one 600px below the window is culled — and both are correct, because in viewport space its own rect is the whole truth about where it lands.

How an ancestor knows: it measures the subtree, it does not read the node’s category. The cull asks layer_bounds::subtree_is_entirely_outside — the same walk that sizes an opacity layer — and prunes a stacking context only on a definite extent that misses the render target. Every not-knowing means paint in full: a position: fixed descendant (which is what keeps the paragraph above true, by construction), a position: sticky one (paint places it by an ancestor walk the subtree does not contain), a position: absolute one that would escape a clip below its containing block (#550), the walk’s visit budget, and its depth cap.

The cull used to answer the syntactic question instead — decline for every stacking context with children — and that was correct and expensive: an opacity: 0.5 sheet parked below the fold allocated, filled and composited a whole-surface pixmap every frame, 26.7ms against 0.8ms on three of them at 1080x2460 (#562). Narrowing it to stacking contexts that also clip is the obvious repair and is wrong: the skip-draw-and-recurse arm it hands the rest to runs before any layer is pushed, so the sheet’s fixed descendant came back unfaded, with the whole suite green. Measuring the subtree is what makes the cheap answer and the correct answer the same one.

And the cull tests a box’s layout rect against the window grown by a margin, not against the window itself, because a box-shadow, an outline or a text run wider than its own box all put ink outside the rect being tested.

Rich-Text Editor Architecture

This page is the structural view of the editor: which crate owns what, how data flows between them, which invariants each boundary exists to protect, and why the boundaries are where they are.

It deliberately does not duplicate the concepts or the API:

  • Rich-Text Editor — the model, positions, schema, steps, transactions, commands, history, plugins. Read that first if you want to understand the editor.
  • Rich-text editing — create_editor(), the Editor {} component, EditorHandle, the command catalogue.
  • docs/design/editor-rearchitecture.md (in the repo, not in this book) — the design record: the milestone plan and the numbered decisions (§6, A3, A7, A22 …) that the source comments cite.

Crate map

Three editor crates plus two platform-wiring crates, split along one axis: how much a crate is allowed to know about a renderer.

CrateKnows aboutRole
rinch-editor-corenothingThe pure model: document tree, positions, schema, steps, state, commands, plugins, serialization. Defines the EditorView seam.
rinch-editor-viewrinch-core’s DomDocument traitThe projection: RinchDomEditorView, EditorHandle, the Editor {} component, the mounted-editor registry, the default stylesheet, the caret-blink clock.
rinch-editor-collaba CRDT engine (yrs)Optional, off by default: projects the model onto a CRDT and rebuilds remote changes back into Steps.
rinchrinch-dom, winit, AT-SPIDesktop wiring: input translation, the layout-coupled caret pass, block virtualization, accessibility, the cross-thread collab inbound.
rinch-webweb_sysBrowser wiring: the same jobs against the real browser DOM.

rinch-editor-core’s manifest states the ban list as a contract — no rinch-dom, winit, web_sys, parley, taffy, vello, and no CRDT engine — and it compiles to wasm32 unchanged. That is not tidiness for its own sake: it is what makes the model testable without a renderer, shareable between desktop and browser, and projectable onto a CRDT without either side learning about the other.

┌──────────────────── rinch-editor-core (pure, wasm-clean) ─────────────────────┐
│  model/  Node · Mark · Fragment · Slice     pos/       Pos · ResolvedPos      │
│  schema/ NodeSpec · MarkSpec · ContentMatch transform/ Step · Mapping         │
│  state/  EditorState · Transaction          commands/ plugins/ serialize/     │
│  view.rs  trait EditorView   ◄── the only outward seam                       │
└───────────────────────────────┬───────────────────────────────────────────────┘
                                │ implements EditorView
┌───────────────────────────────▼───────────────────────────────────────────────┐
│  rinch-editor-view — RinchDomEditorView over ANY rinch-core DomDocument       │
│  EditorHandle (owns state + view) · Editor {} component · registry · styles   │
└──────┬───────────────────────────────┬────────────────────────────┬───────────┘
       │ desktop host                  │ browser host               │ optional
┌──────▼─────────────────────┐  ┌──────▼──────────────────┐  ┌──────▼───────────┐
│ rinch — rinch-dom renderer │  │ rinch-web — web_sys DOM │  │ rinch-editor-    │
│ input glue · caret pass    │  │ input glue · caret pass │  │ collab (yrs)     │
│ a11y · block virtualization│  │                         │  │ `collaboration`  │
└────────────────────────────┘  └─────────────────────────┘  └──────────────────┘

The invariant everything else serves

The model is the only source of truth, and the host tree is derived from it.

EditorState { doc, selection, stored_marks, plugin_state } is a value. state.apply(tr) is pure and returns a new state. The host tree — rinch-dom nodes on desktop, real DOM elements in the browser — is a projection of that state and is never read back for content. There is no contenteditable attribute anywhere; the editor container is marked data-pm-editor precisely so nothing mistakes it for a browser-managed editing surface.

Everything downstream is a consequence:

  • One mutation path. Every edit becomes a Transaction of invertible Steps, applied by EditorState::apply, then projected. In rinch-editor-view that funnel is literally one function, EditorCore::commit: store the new state, call the view’s phase-1 update, and (if collaborating) record the change onto the CRDT. EditorHandle::update and EditorHandle::command are the only two entry points that reach it, and typing, paste, IME commit and toolbar clicks all route through one of those two.
  • Commands read state, never the DOM. Toolbar enablement (can_run), mark state (is_mark_active), block type (current_block_type) are all state queries. A command that consulted the host would be reading its own output.
  • “The DOM and the model disagree” is structurally impossible — the failure class that motivated the rewrite. The previous architecture had several mutation paths writing to different sources of truth (the host tree, an Automerge document, a second cursor model) with no reconciliation between them; the design record’s audit names that as the root cause.

rinch-editor-core — the pure layer

The guide covers what these are; the architectural points are why each is a single thing rather than several:

  • One position space. Pos is a single depth-aware integer space (one unit per node boundary, one per Unicode scalar of text). Byte offsets exist only transiently at platform seams — Parley layout, IME — and never in the model. A second position representation would need a conversion at every boundary, and every conversion is a place for the two to drift.
  • The schema is enforced at the step boundary, not by convention. Content expressions compile to a ContentMatch NFA. Every replace reconstructs its parent through one gate that checks the resulting child sequence against that parent’s content expression and each child’s marks against the allowed set; a violation is a StepError, the step is never accumulated into the transaction, and the edit is a no-op. Invalid structure is therefore unrepresentable in a committed state, so no downstream consumer — view, serializer, CRDT projection — needs a defensive path for it.
  • A deliberately minimal Step set. ReplaceStep, ReplaceAroundStep, AddMarkStep, RemoveMarkStep, SetNodeAttrStep, SetDocAttrStep. Steps are invertible (undo), mappable (redo, decoration tracking, collaboration rebase) and composable. Collaboration adds no new step kinds — that is the test of whether the set is actually primitive. BatchStep (#1200) is the one addition, and it adds no expressiveness: it is a list of disjoint replaces and attribute changes that builds the document those ReplaceSteps and SetNodeAttrSteps would one at a time, maps positions as they would, and inverts — as one step, so a table command touching every row is linear rather than quadratic. Where it differs (content checked on the result only; a rebase that brings two replaces together) is in docs/src/guide/editor.md.
  • Persistent tree, so identity is cheap. Node is Rc-shared and every edit produces a new tree sharing unchanged subtrees. Node::same_ref (an Rc::ptr_eq) therefore answers “did this subtree change?” in constant time — which is what makes history cheap to keep and the view diff cheap to run.
  • Nothing is special-cased. The command catalogue + keymap (BaseCommandsPlugin), the markdown input rules (MarkdownInputRulesPlugin) and undo/redo (HistoryPlugin) are all plugins; default_plugins() just lists them. History has no privileged hook in apply — it folds its state forward like any other plugin. Features that cannot be added as plugins are a design smell, not an excuse to reach into the core.

The view seam

rinch-editor-core::view defines the whole outward contract in two methods:

#![allow(unused)]
fn main() {
pub trait EditorView {
    fn update_dom(&mut self, prev: &EditorState, next: &EditorState) -> Vec<ViewRequest>;
    fn update_caret(&mut self, next: &EditorState) -> Vec<ViewRequest>;
}
}

Three structural decisions live in that shape.

It is two phases because layout sits between them. update_dom runs before layout and mutates the host. update_caret runs after layout, once the host has fresh geometry, and computes caret/selection rectangles and the IME candidate box. Caret geometry computed in the call that mutates the host would read stale layout — so the seam straddles the host’s mutate → layout → measure pipeline rather than pretending it isn’t there.

Requests cross back as data. The view owns no window; only the runtime does. So “scroll the selection into view” is a returned ViewRequest the runtime fulfils, not a call the view makes.

Input translation is deliberately absent from the trait. Turning a key, pointer or IME event into a command is irreducibly platform-specific — desktop reads Parley geometry to map a click to a Pos; the browser has its own event model — so each runtime owns that glue. The trait covers only the state→host direction, which is the part that can be shared.

rinch-editor-view — one projection, two hosts

RinchDomEditorView implements EditorView against rinch_core::dom::DomDocument, the same trait the reactive layer uses. It holds only a Weak reference to the document and never names a concrete renderer — which is why the desktop (rinch-dom) and browser (web_sys) editors are the same view code, and why its own tests project onto a mock document with no renderer present at all.

The ViewDesc tree mirrors the document: one descriptor per model node, holding its host node, its mark-wrapper chain, and its children. Diffing walks descriptors against the new document, with Node::same_ref as a fast skip — an unchanged subtree costs one pointer comparison. The diff is not keyed: it first matches the children the new node shares by reference at the start and at the end, then diffs the changed stretch between them positionally, inserting new children before the unchanged suffix. An edit typically changes one contiguous stretch of siblings, so that isolates it without a keyed/LIS pass. Matching the suffix is what keeps a split or join local: a purely positional diff re-pointed every later block at its neighbour’s host and rewrote its text, so one Enter re-shaped every paragraph below the caret (issue #905). A node whose kind changed (tag or mark set) is rebuilt and swapped rather than patched in place, because a changed mark set alters the wrapper chain around it. The root’s host element is created by the caller (the Editor component) and is never replaced — only its children reconcile.

Decorations diff independently of the document. A transaction that changes only decorations — an IME preedit, a placeholder appearing — must still produce a visible update, so update_dom runs the document diff and a separate decoration sync. Folding them together would make preedit invisible whenever the document happened not to change.

EditorHandle owns both the state and its projection, behind an Rc<RefCell<…>> so it is cheap to clone into toolbar closures and the runtime alike. It works unmounted: create_editor() builds a handle over a fresh document with no host at all, and load_html / command / set_selection operate on the owned state, which the view renders when the Editor {} component mounts it. This is what lets a handle be created in a component body and handed to buttons and to Editor {} in the same rsx! block.

Read-only is one check where local changes land, not one per input path. Every local change — update, command, insert_image, toggle_link, load_doc — is stored by EditorCore::commit, and that is where set_read_only is enforced: a transaction that changes the document or sets stored marks is refused, a selection-only one applies, and a load applies unless a collaboration session would record it. The rule reads the two states, not the caller, so the desktop key handler, the web’s beforeinput and mirror-diff paths, an app’s toolbar and any input path added later are covered without knowing the switch exists. Remote integration (collab_receive) has never gone through commit — it must not be recorded back onto the CRDT — so a read-only editor keeps receiving by construction: there is no flag to lower around it and none to forget to raise again. The platform crates add only what a refusal cannot express (the OS input method off, the context menu’s Cut and Paste greyed, the web capture field readonly so no soft keyboard rises).

The registry is keyed by (doc_key, container_id), not by container id alone. Container ids are per-document slab indices, so two documents on one thread — two desktop windows, or two embedded RinchContexts — collide at the same id (issue #134). Keyboard focus is not tracked here: the platform runtime is the single focus authority, and it resolves its focused container id to a handle through the registry. The Editor component registers on mount and unregisters via scope.on_cleanup, so a hidden tab’s editor stops being driven while its handle (and state) may live on.

Platform wiring

The wiring crates hold exactly what cannot cross the DomDocument seam.

Desktop (crates/rinch/src/editor/) re-exports rinch-editor-view wholesale and adds three things that reach past the seam into the rinch-dom renderer or a native platform API:

PathWhy it can’t be in the shared view
virtualization.rs, virtual_window.rsBlock virtualization gives off-screen blocks a fixed estimated height so Taffy skips their Parley measurement. It manipulates the concrete Taffy layout tree.
a11y.rsThe accessibility derivation is portable (rinch_editor_core::a11y → an accesskit::TreeUpdate); only the adapter is not. Linux uses accesskit_unix over AT-SPI; other desktops get a no-op bridge.
post_remote_deltaThe Send-safe collaboration inbound: marshals a delta from a network thread onto the main thread, then integrates it and wakes the runtime.

Input translation and the caret pass live in the runtime proper: app/event_dispatch.rs turns platform key/pointer/IME events into KeyBindings and handle calls, app/focus.rs holds the focus arbiter, and shell/rinch_runtime.rs drives the post-layout caret pass and the blink clock.

Browser (crates/rinch-web/) does the same jobs against the browser DOM in editor_input.rs, and re-exports Editor / EditorHandle / create_editor so web app code is identical to desktop app code. rinch-web depends on rinch-editor-view unconditionally; on desktop the editor arrives with the desktop feature.

Serialization

rinch-editor-core::serialize is the durability boundary, and its contract is totality: a node or mark either round-trips faithfully or the boundary returns an error. Nothing is dropped silently and nothing is rendered to a fallback tag.

ModuleShapeNotes
doc_json (feature serde)DocNodeThe durable save/load shape. Node::to_doc() ⇄ Schema::node_from_doc(). Unknown types and missing required attrs are hard errors.
htmlHTML stringSchema-driven tags (node_dom_tag / mark_dom_tag), whitelist parse. The same table the view uses to pick host tags and that Editor { content: … } / load_html parse with.
markdown (feature markdown)markdownVia pulldown-cmark.
textplain textThe lossy text/plain clipboard fallback.

That the view and the serializers share one tag table is the point: copy-out, paste-in, and on-screen rendering cannot disagree about what a heading is.

Collaboration — an optional projection

rinch-editor-collab projects the model onto a yrs (Yjs) CRDT and rebuilds remote CRDT changes back into Steps. Architecturally it is a peer of the view: a second projection of the same model, sitting behind the same one-way mutation path. The model does not know it exists.

  • Off by default. Gated behind the collaboration feature, so default builds — desktop and web — link zero CRDT code. This is the only crate in the workspace that links a CRDT engine.
  • One invariant: model ≡ project(model). Every local step is projected onto the CRDT; every remote change is rebuilt into the model. Convergence then follows from the CRDT’s own convergence rather than from hand-written merge logic.
  • Fail loud, never silently drop. Content outside the staged scope returns CollabError::Unsupported and leaves the CRDT untouched — projection is all-or-nothing. A silent drop would reintroduce exactly the divergence class the rewrite eliminated.
  • wasm-compatible with no shims, so the desktop and browser editors share one adapter instead of bridging to a separate JS CRDT.

For the API, the staged scope, the transport contract and the engine-choice rationale see the Collaboration section of CLAUDE.md, the Rich-text editing guide, and docs/design/yrs-migration.md.

Not the rich-text editor: rinch-editable

rinch-editable is a separate, unrelated engine for single-line <input> and <textarea> widgets: StringDocument, EditCommand, EditableState, InputHandler, and its own Position/Selection/UndoStack types. It shares no code and no types with rinch-editor-core — the identical names live in different crates. The quickest tell: rinch-editable’s Selection is a struct of two Positions, while rinch-editor-core’s is an enum of Text/Node/Cell.

The split is intentional: a text field does not need a schema, steps, or a CRDT, and the rich editor should not carry a second document model for the sake of one.

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

App is the entry point. Every startup option is one method, and they all compose — which the old run_* functions could not do (a menu bar and window props needed two different functions, and neither could take anything added later).

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        div {
            h1 { "Hello, rinch!" }
            p { "A lightweight GUI framework for Rust." }
        }
    }
}

fn main() {
    App::new(app).title("My App").size(800, 600).run();
}
MethodPurpose
App::new(component)Start configuring around the root component
.title(title)Window title
.size(width, height)Initial size, in logical pixels
.theme(theme)ThemeProviderProps — colors, radius, dark mode
.menu(menus)Native menu bar, as Vec<(&str, Menu)>
.window_props(props)Full WindowProps — borderless, transparent, icon, app_id, …
.gpu_config(cfg)Raise the compositor device’s features/limits (gpu feature)
.external_gpu(gpu)Composite onto an embedder-provided device (gpu feature)
.run()Start on desktop; takes over the thread until the event loop exits
.run_android(android_app)Start on Android (android feature, Android target)

.title() and .size() are applied over .window_props() whatever the call order, so a title set explicitly is never silently lost to a later window_props. Every other window field comes from props.

Nothing happens until a terminal method is called. A terminal method takes over the thread and returns only when the application exits its event loop, so it is normally the last statement in main.

With a theme

use rinch::prelude::*;

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("cyan".into()),
        default_radius: Some("md".into()),
        ..Default::default()
    };
    App::new(app)
        .title("Themed App")
        .size(800, 600)
        .theme(theme)
        .run();
}

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()
    };
    App::new(app).window_props(props).run();
}

Everything at once

This combination is the reason App exists — no run_* signature could express it:

#![allow(unused)]
fn main() {
App::new(app)
    .window_props(props)
    .theme(theme)
    .menu(vec![("File", file_menu), ("Edit", edit_menu)])
    .run();
}

Deprecated run_* functions

run, run_with_theme, run_with_menu, run_with_window_props, run_with_window_props_and_menu, run_with_gpu_config and run_with_external_device still exist as thin shims over App, but they are #[deprecated], and each one’s deprecation note names the equivalent builder chain. The two Android entry points (run_android, run_android_with_theme) are deprecated the same way, in favour of .run_android(android_app).

Behaviour through the shims is unchanged with one deliberate exception: unifying the startup paths also unified the Linux Wayland app_id, which is now derived per application from the executable name rather than defaulting to the shared constant "rinch-app". An explicit WindowProps::app_id still wins; X11 WM_CLASS and non-Linux platforms are unaffected.

Prelude

Import commonly used types with the prelude:

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

This includes:

Entry points (desktop feature):

  • App — the builder; run, run_with_theme (deprecated shims)

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. The entry point is rinch::App.

Deprecated shims over App:

  • run(), run_with_theme(), run_with_menu() - title/size, plus theme or menu bar
  • run_with_window_props(), run_with_window_props_and_menu() - full window props
  • run_with_gpu_config(), run_with_external_device() - GPU device selection (gpu feature)

Lower-level runtime entry points, deprecated since 0.2.0 and not shims over App — they predate it and sit below the layer that installs theme CSS, so unlike App::run they load no theme at all:

  • run_rinch(), run_rinch_with_window_props()

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 (passed at the runtime level via App::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);
    // Renders a value into an attribute: for an HTML boolean attribute
    // (`disabled`, `checked`, `selected`, …) a truthy value writes the bare
    // presence form and a falsey one removes it. `set_attribute` stays literal.
    pub fn write_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