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();
}
}
| API | Purpose |
|---|---|
mount_into(&Element, theme, build) -> RootHandle | Mount 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,
);
}
}
| API | Purpose |
|---|---|
mount_with_menu_bar(theme, menus, build) | Whole-page app under a menu bar |
mount_into_with_menu_bar(&Element, theme, menus, build) -> RootHandle | An 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
With Trunk (Recommended)
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
ClipboardEventdoes not carry itsclipboardDatain Firefox.new ClipboardEvent("paste", { clipboardData })hands the listeners an empty transfer there. Build clipboard events withtests/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 —
RenderSurfaceandRinchContextare 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/MenuItemdeclarations 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.