fui-rs: Functional UI in Rust, Without HTML or CSS

A look at fui-rs, a Rust SDK for retained-mode functional UI on EffinDom: no HTML/CSS, WebAssembly delivery, custom drawing, keyboard input, game loops, and static deployment.

fui-rs: Functional UI in Rust, Without HTML or CSS

There is an interesting idea hiding in plain sight in the frontend world: what if application UI did not have to be described as a pile of HTML nodes, CSS rules, and framework-specific reconciliation logic?

That is the direction of fui-rs:

https://github.com/zion-sati/fui-rs

fui-rs is a Rust SDK for building functional UI applications on top of the EffinDom runtime. You write retained-mode UI in Rust, compile it to WebAssembly, and render through the shared browser runtime. The mental model is closer to building an application scene graph than hand-authoring DOM markup.

No HTML templates.
No CSS selectors.
No virtual DOM ceremony.

Just Rust app code, retained controls, callbacks, and custom drawing when you need pixels.

You can see an example running here:

https://fui-rs-demo.effindom.dev

Why this is interesting

Most frontend stacks split the application across several languages and layers:

  • HTML for structure
  • CSS for layout and visual state
  • JavaScript or TypeScript for behavior
  • a framework runtime to keep all of that synchronized

That works, but it also creates a lot of incidental complexity. Many bugs are not really product bugs — they are sync bugs between state, DOM, CSS classes, lifecycle hooks, effects, and framework assumptions.

fui-rs takes a different route. The UI is built as retained Rust objects. You construct controls once, keep handles to the pieces you need to mutate, and update them directly from callbacks.

A tiny counter-style page looks like this:

use fui::prelude::*;
use std::cell::Cell;
use std::rc::Rc;

#[derive(Clone)]
struct CounterPage {
    root: FlexBox,
    count_label: Text,
}

fui_component!(CounterPage => root);

impl CounterPage {
    fn new() -> Self {
        let count_label = text("Count: 0");
        let button = button("Increment");
        let count = Rc::new(Cell::new(0));

        button.on_click({
            let (count_label, count) = (count_label.clone(), count.clone());
            move |_| {
                let next = count.get() + 1;
                count.set(next);
                count_label.text(format!("Count: {next}"));
            }
        });

        let root = ui! {
            column().padding(16.0, 16.0, 16.0, 16.0).gap(8.0) {
                count_label.clone(),
                button,
            }
        };

        Self { root, count_label }
    }
}

fui_managed_app!(CounterPage, CounterPage::new, |p: &CounterPage| p.root.clone());

The important detail is ownership. The page owns its retained UI handles. The callback owns cheap clones of the label and the shared state. When the button is clicked, the label is mutated directly:

count_label.text(format!("Count: {next}"));

That is the kind of model Rust developers already understand: explicit state, explicit ownership, explicit mutation.

The retained-mode model

The main thing to get right with fui-rs is that you do not recreate the interface in a render loop.

You build controls once. You retain handles. You mutate the handles when state changes.

That makes it suitable for app-style UI where you care about focus, input state, scroll position, and subscriptions. Rebuilding controls repeatedly would throw away that state. Retaining them keeps the app stable.

The SDK provides macros and builders for this model:

  • fui_app!(RootType, build_fn) for simple apps
  • fui_managed_app! for retained page/controller ownership
  • fui_component! for wrapping a struct around a root node
  • ui! { ... } for building mixed child trees ergonomically
  • column(), row(), flex_box() and fluent layout setters
  • text(), button(), inputs, switches, sliders, dialogs, scroll boxes, and other controls

App code imports the prelude:

use fui::prelude::*;

and the framework macros emit the lifecycle exports needed by the WASM harness. You do not hand-write those exports yourself.

Custom drawing and games

The part I find especially promising is custom_drawable.

For normal UI, retained controls are enough. But for visual tools, canvas-heavy applications, games, simulations, dashboards, editors, and interactive diagrams, you eventually need immediate-mode drawing.

fui-rs exposes that through a custom drawable node:

let canvas = custom_drawable(move |ctx| {
    ctx.draw_rect(0.0, 0.0, W, H, Paint::fill(rgb(6, 8, 22)));
    ctx.draw_circle(x, y, 10.0, Paint::fill(rgb(90, 220, 255)));
});

canvas.width(W).height(H);

The drawing model is dirty-driven. The drawable paints once, and then paints again when you call:

canvas.mark_dirty();

That is a good fit for efficient UI. There is no hidden assumption that every app needs a 60fps render loop. If you do need animation, you drive it explicitly with a self-rearming timer:

fn schedule_tick(state: Weak<GameState>, canvas: CustomDrawable) {
    set_timeout(33, move || {
        let Some(state) = state.upgrade() else { return; };
        state.step();
        canvas.mark_dirty();
        schedule_tick(Rc::downgrade(&state), canvas);
    });
}

That pattern is simple and very Rust-shaped:

  • game state lives in Rc<Cell<_>> / Rc<RefCell<_>>
  • the timer holds a Weak reference so it stops when the page drops
  • the canvas is marked dirty after state changes
  • keyboard input is attached to the focused drawable

For example, an animated game canvas can be made focusable and receive keyboard input like this:

canvas.width(W).height(H).focusable(true, 0);

canvas.on_key_down({
    let s = state.clone();
    move |e| match e.key.as_str() {
        "ArrowLeft" => { s.left.set(true); e.handled = true; }
        "ArrowRight" => { s.right.set(true); e.handled = true; }
        _ => {}
    }
});

on_loaded({
    let canvas = canvas.clone();
    let state = state.clone();
    move |_| {
        canvas.focus_now();
        schedule_tick(Rc::downgrade(&state), canvas.clone());
    }
});

This makes fui-rs interesting not only for forms and dashboards, but also for more advanced use cases:

  • small browser games
  • interactive demos
  • design tools
  • visual editors
  • simulation UIs
  • custom charting surfaces
  • educational apps
  • Rust-first WASM applications where the DOM is not the center of the architecture

The demo at https://fui-rs-demo.effindom.dev is a good example of the direction: a functional UI application running in the browser through the EffinDom runtime, with the app logic authored in Rust and delivered as a static web bundle.

Deployment is just static hosting

The output is a static bundle: HTML shell, runtime JavaScript, and WebAssembly.

That means deployment can be boring in the best possible way. Build the app, serve the generated static directory with nginx or any static host, and make sure .wasm is served as application/wasm.

A minimal Docker deployment shape is:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/public /usr/share/nginx/html
EXPOSE 80

The build stage needs Rust and the wasm32-unknown-unknown target, but the runtime stage is just static files.

That makes fui-rs a natural fit for cheap deployments on static hosting, small VPS setups, or decentralized compute platforms where you want a simple container serving a compiled frontend bundle.

Why I like the direction

The web platform is powerful, but a lot of modern frontend work is spent managing accidental complexity. fui-rs is exploring a cleaner axis:

  • Rust for application logic
  • retained UI instead of repeatedly rebuilding the interface
  • explicit state ownership
  • immediate-mode drawing when pixels matter
  • WebAssembly for delivery
  • static hosting for deployment

It will not replace every web stack, and it does not need to. The interesting use case is Rust-first applications where the UI is an application surface, not a document.

For those cases, the pitch is strong:

Build the app in Rust. Render through EffinDom. Ship a static bundle.

Repository:
https://github.com/zion-sati/fui-rs

Live demo:
https://fui-rs-demo.effindom.dev

If you are interested in Rust, WebAssembly, retained-mode UI, or building browser apps without the usual HTML/CSS framework stack, fui-rs is worth watching.


Write a comment