Day and the other Rust UI frameworks
Rust UI frameworks differ in how they render controls and manage state. Day uses platform widgets and signal bindings. The comparisons below relate its APIs to other Rust frameworks, with code examples and notes on where each approach fits. Why Day covers the general tradeoffs.
Where each one draws
| Version | What draws the pixels | Targets | State model | |
|---|---|---|---|---|
| Day | — | the platform’s widgets | macOS, Windows, Linux, iOS, Android, HarmonyOS, web | fine-grained signals |
| Tauri | 2.11 | a system webview (your HTML/CSS) | desktop + iOS/Android | whatever your JS framework does; Rust state behind IPC |
| Slint | 1.17 | its own renderer (Skia, software, or OpenGL) | desktop, embedded, MCU, mobile, wasm | properties and callbacks declared in a DSL |
| Iced | 0.14 | its own renderer (wgpu or tiny-skia) | desktop, wasm | the Elm architecture: message in, state out |
| Leptos | 0.8 | the browser’s DOM | web (client and server) | fine-grained signals |
A framework with its own renderer implements text input, scrolling physics, accessibility, and IME itself. A framework in a web view gets those from the browser and puts a boundary between Rust and the UI. Day uses the platform’s widgets for all of them, with a smaller widget vocabulary and a look it leaves to the platform.
Tauri
In Tauri, your interface is a web app in the OS’s webview (WKWebView, WebView2,
WebKitGTK), and Rust is the backend it talks to. Tauri 2 added iOS and Android, so the reach is
comparable to Day’s. The differences are the language boundary (every call is a serialized invoke
across process lines) and permissions, which Tauri models explicitly in capability files.
// src-tauri/src/lib.rs
#[tauri::command]
fn bump(
by: i64,
state: tauri::State<Counter>,
) -> i64 {
state.add(by)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(Counter::default())
.invoke_handler(tauri::generate_handler![bump])
.run(tauri::generate_context!())
.expect("error while running tauri");
}use day::prelude::*;
fn counter() -> impl Piece {
let count = Signal::new(0i64);
column((
label(move || format!("{} clicks", count.get())),
button("+").action(move || count.update(|c| *c += 1)),
))
.spacing(12.0)
.padding(16.0)
}The Tauri code above is only the Rust half. The UI is separate:
// src/main.js — the other side of the boundary
import { invoke } from '@tauri-apps/api/core';
document.querySelector('#inc').addEventListener('click', async () => {
const count = await invoke('bump', { by: 1 }); // camelCase args, JSON, async
document.querySelector('#out').textContent = `${count} clicks`;
});
Reading a file shows the same split. Tauri routes it through a plugin and a capability the app declares up front; Day calls a part in-process, and the API itself enforces the sandbox through relative, app-scoped paths.
// src-tauri/capabilities/default.json
{
"identifier": "default",
"windows": ["main"],
"permissions": ["fs:allow-appdata-read"]
}// paths are relative and app-scoped; an absolute path
// or a `..` segment is an error before any backend runs
let bytes = day_part_fs::read("notes/today.txt")?;
// or, on every target including the web:
day::task(async move {
match day_part_fs::read_future("notes/today.txt").await {
Ok(bytes) => notes.set(String::from_utf8_lossy(&bytes).into_owned()),
Err(e) => notes.set(format!("error: {e}")),
}
});// and the JS that consumes the capability above
import { readTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
const text = await readTextFile('notes/today.txt', { baseDir: BaseDirectory.AppData });
Pick Tauri when your UI is already a web app, your team’s skill is web, or you want a design that looks identical everywhere and is styled by CSS. Pick Day when you want the platform’s own controls, one language end to end, and a native binary.
Slint
Slint shares Day’s aim, a native-feeling UI from one description from
microcontrollers up to desktop, and differs in method. UIs are written in a .slint DSL that
compiles to Rust (or C++, or JS bindings), and Slint renders the result itself, so a Slint app looks
like a Slint app on every platform. Its runtime fits in well under a megabyte, so it also targets
embedded hardware and microcontrollers, which Day does not.
// ui/counter.slint
export component Counter inherits Window {
in-out property <int> count: 0;
callback increment();
VerticalBox {
Text {
text: "\{root.count} clicks";
}
Button {
text: "+";
clicked => { root.increment(); }
}
}
}use day::prelude::*;
fn counter() -> impl Piece {
let count = Signal::new(0i64);
column((
label(move || format!("{} clicks", count.get())),
button("+").action(move || count.update(|c| *c += 1)),
))
.spacing(12.0)
.padding(16.0)
}The Rust side of that Slint component is generated, and you wire it from main:
slint::include_modules!();
fn main() -> Result<(), slint::PlatformError> {
let ui = Counter::new()?;
ui.on_increment({
let ui = ui.as_weak();
move || {
let ui = ui.unwrap();
ui.set_count(ui.get_count() + 1);
}
});
ui.run()
}
Slint’s markup iterates a model the Rust side owns; Day’s list is a function call, and the key that identifies a row is an argument.
export component Albums inherits Window {
in property <[string]> titles;
VerticalBox {
for title in root.titles: Text { text: title; }
}
}list(
move || albums.get(),
|a| a.id,
|slot: ItemSlot<Album, u64>| {
label(move || slot.get().title).padding(8.0)
},
)// the Rust that feeds the Slint model
let model = std::rc::Rc::new(slint::VecModel::from(vec![
slint::SharedString::from("Kind of Blue"),
]));
ui.set_titles(model.clone().into()); // Rc<VecModel<_>> → ModelRc<_>
model.push(slint::SharedString::from("Blue Train"));
Pick Slint when you target embedded or MCU hardware, want a live-preview design workflow, or want the same pixels on every screen. Pick Day when the app should use each platform’s own controls, and you would rather write Rust than a second language for the UI.
Iced
Iced brings the Elm architecture to Rust: state, a Message enum, an update
that folds messages into state, and a view that renders state into widgets. It draws with wgpu
(or tiny-skia on machines without a GPU path). Every state change has a name, which makes the
result predictable, and the tradeoff is that a keystroke is a message, a variant, a match arm,
and a re-view of that part of the tree.
use iced::widget::{button, column, text, Column};
pub fn main() -> iced::Result {
iced::run(update, view)
}
#[derive(Debug, Clone)]
enum Message {
Increment,
}
fn update(count: &mut u64, message: Message) {
match message {
Message::Increment => *count += 1,
}
}
fn view(count: &u64) -> Column<Message> {
column![
text(format!("{count} clicks")),
button("+").on_press(Message::Increment),
]
}use day::prelude::*;
fn counter() -> impl Piece {
let count = Signal::new(0i64);
column((
label(move || format!("{} clicks", count.get())),
button("+").action(move || count.update(|c| *c += 1)),
))
.spacing(12.0)
.padding(16.0)
}The two models handle asynchronous work differently. Iced keeps update pure by returning a Task
the runtime performs, delivering the result as another message. Day awaits inline and writes the
signal, because the continuation runs on the UI thread.
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::Fetch => Task::perform(load(url), Message::Loaded),
Message::Loaded(Ok(body)) => {
state.body = body;
Task::none()
}
Message::Loaded(Err(e)) => {
state.body = format!("error: {e}");
Task::none()
}
}
}let body = Signal::new(String::new());
day::task(async move {
match day_part_http::fetch_future(request).await {
Ok(resp) => body.set(resp.text().into_owned()),
Err(e) => body.set(format!("error: {e}")),
}
});Pick Iced when you want one enumerated state machine for the whole app, a pure update, and
a look you control completely. Pick Day when you want native controls and would rather bind a
signal than name every transition.
Leptos
Of the four, Leptos is closest to Day. It is a full-stack web framework whose reactive core is the same idea as Day’s: signals that track their readers, memos that recompute only when their inputs change, and effects that clean up with their scope. The counter is nearly line-for-line, and the target differs: Leptos patches DOM nodes in a browser (with SSR and hydration), and Day patches native widgets on nine backends, one of which is also the DOM.
#[component]
fn Counter() -> impl IntoView {
let (count, set_count) = signal(0);
view! {
<p>{move || format!("{} clicks", count.get())}</p>
<button on:click=move |_| set_count.update(|n| *n += 1)>
"+"
</button>
}
}fn counter() -> impl Piece {
let count = Signal::new(0i64);
column((
label(move || format!("{} clicks", count.get())),
button("+").action(move || count.update(|c| *c += 1)),
))
.spacing(12.0)
}Derived state is the same primitive with the same name, and both recompute only when the value changes:
let total = Memo::new(move |_| {
items.get().iter().map(|i| i.price).sum::<f64>()
});
view! { <p>{move || format!("{total:.2} €", total = total.get())}</p> }let total = Memo::new(move || {
items.with(|v| v.iter().map(|i| i.price).sum::<f64>())
});
label(move || format!("{:.2} €", total.get()))The differences lie outside the reactive core: Leptos has server functions, hydration, and
the whole CSS toolbox; Day has native controls, and its web-dom backend is one target among
many. If your product is a website, Leptos is the right framework. If it’s an app that also
wants a web build, web-dom is that build.
Other Rust UI projects
| Project | Version | Shape |
|---|---|---|
| egui | 0.36 | Immediate mode: the UI is re-declared every frame, drawn by egui. It fits tools, debug overlays, and anything in a game loop, and fits less well where the app should look like the platform. |
| Dioxus | 0.7 | React-shaped RSX plus signals, rendering to a webview on desktop and mobile, the DOM on web, with a native renderer (Blitz) in progress. It is the closest thing to React in Rust. |
| Xilem / Masonry | 0.4 | The Linebender group’s current toolkit (the successor to their discontinued Druid), drawing with Vello on the GPU. It is pre-1.0 and changing; the architecture diffs a view tree against a retained widget tree. |
| gtk4-rs / cxx-qt | — | Toolkit bindings: real native widgets, one toolkit each, and you write the app per platform. Day uses gtk4-rs for its own GTK backend, so gtk4-rs is a layer Day builds on. |
Where to go next
- Why Day — the tradeoff in full, including when to pick something else.
- Overview — the targets and the model.
- Pieces and Reactivity — the authoring surface and the signal graph, for comparison against the models above.
- Platform support — how much testing each target gets.