Skip to content

Rust GUI Apps: Tauri, egui, Slint and Iced

Sooner or later you’ll want a real window with buttons, not only text in a terminal. Can you build that in Rust? Yes. Rust GUI apps are fast, small and safe, and several good toolkits exist today. But the Rust GUI world is younger than, say, C# with WPF or JavaScript with Electron, so it helps to know the landscape before you pick one.

This page gives you an honest overview, a table to help you choose, the idea behind immediate mode and retained mode, and a first small program for the most popular toolkits.

Where can you run the code? A GUI needs a real window, so the egui, Tauri, Slint and Iced programs are meant for your own computer with cargo run. The egui and Iced programs on this page were compiled with cargo check against the current versions. Two sections model a GUI in plain Rust that prints text instead of drawing a window, so they run in the browser. Look for the “Try it Yourself” buttons.

The community site Are we GUI yet? sums it up in one line: “The roots aren’t deep but the seeds are planted.” That’s still fair:

  • Great: speed, small apps, and the safety of Rust. egui, Iced and Slint draw everything themselves, so one code base runs on Windows, macOS and Linux. Tauri lets you reuse web skills and keeps the app much smaller than Electron, because it uses the webview that’s already on the system.
  • Still growing: there is no single “standard” toolkit like Qt in C++ or WPF in C#. Several popular crates are still at version 0.x, so updates can break your code. Accessibility, right-to-left text and native look-and-feel vary from toolkit to toolkit.
  • The good news: you don’t have to find the perfect toolkit. For a first app, any toolkit in the table below works well.

For a longer comparison, the 2025 survey of Rust GUI libraries tests many toolkits one by one.

Real apps you can look at today:

  • The Rerun Viewer, a tool for looking at data from robots and computer vision, is built with egui. (Rerun also sponsors egui’s development.)
  • COSMIC, the desktop environment from System76 for Linux, is built with Iced. So is the IRC chat client Halloy.
  • Desktop tools with a web-style interface: note apps, dashboards and small business tools are a common fit for Tauri.
  • Screens on devices: Slint also runs on microcontrollers, so it’s a good fit for touch screens on machines and appliances.

Versions are the latest releases in October 2026.

Crate Version How you write the UI Runs on Pick it when
Tauri 2.12 (3.0 in alpha) HTML, CSS and JavaScript for the screen; Rust for the logic Windows, macOS, Linux, Android, iOS You know web development, or you want a rich, styled interface
egui + eframe 0.37 Plain Rust, immediate mode Windows, macOS, Linux, web (Wasm) Tools, debug panels, editors, and the fastest start in pure Rust
Slint 1.18 Its own .slint markup, plus Rust for the logic Desktop, embedded, mobile, web A polished app, or a screen on an embedded device
Iced 0.14 Plain Rust, Elm architecture (state, messages, update, view) Windows, macOS, Linux, web You like clean, predictable code with all changes in one place
gtk-rs (gtk4) 0.11 Rust bindings to GTK 4, retained mode Linux first; Windows and macOS work with more setup Native-looking apps for the GNOME desktop on Linux
Dioxus 0.7 React-like components with an rsx! macro Web, desktop (webview), mobile You know React and want one code base for web and desktop

A few honest notes about licenses and maturity:

  • Slint is free under the GPLv3, or free for closed-source desktop, mobile and web apps under its royalty-free license (you must show that you use Slint). Closed-source embedded products need a paid license. Read slint.dev/pricing before you ship.
  • egui, Iced and Dioxus are still at 0.x. Expect small breaking changes with each release, and check that a tutorial matches your version. For example, older egui tutorials use fn update(&mut self, ctx: &egui::Context, ...), while eframe 0.37 uses fn ui(&mut self, ui: &mut egui::Ui, ...).
  • Tauri, Dioxus desktop and gtk-rs depend on system libraries. See Platform prerequisites below.

Toolkits disagree on one big question: who remembers the widgets?

In retained mode (gtk-rs, Slint, Tauri’s web page, and most toolkits in other languages), you create a button once. The toolkit keeps it in a tree of widgets. You attach a callback, and when the user clicks, the toolkit calls your callback. To change the button’s text later, you need a handle to that button.

In immediate mode (egui), nothing is kept. Your UI function runs again for every frame and simply describes what to show right now. A button is drawn and checked in one line: if ui.button("+").clicked() { ... }. There are no callbacks and no handles. Your app’s state lives in your own struct, and the UI is always drawn from it, so the screen can never get out of sync with your data.

The cost: the toolkit lays out the whole UI on every frame, so a huge UI takes more CPU time, and some layouts (like centering a window whose size isn’t known yet) are harder. egui only draws a new frame when something happens, like a mouse move, so an idle app uses almost no CPU.

Here is a tiny, pretend immediate-mode toolkit in plain Rust. Each loop step is one frame. The clicks array plays the role of the mouse:

// A pretend "Ui" that records what was drawn this frame.
struct Ui {
clicked: Option<&'static str>, // the button the mouse clicked this frame
drawn: Vec<String>,
}
impl Ui {
fn label(&mut self, text: &str) {
self.drawn.push(text.to_string());
}
// Draws a button AND tells you right away if it was clicked.
fn button(&mut self, text: &'static str) -> bool {
self.drawn.push(format!("[{text}]"));
self.clicked == Some(text)
}
}
// Your UI code: called again for every frame.
fn draw(count: &mut i32, ui: &mut Ui) {
ui.label(&format!("Count: {count}"));
if ui.button("+") {
*count += 1;
}
if ui.button("Reset") {
*count = 0;
}
}
fn main() {
let mut count = 0; // the app state lives outside the UI code
let clicks = [None, Some("+"), Some("+"), None, Some("Reset"), None];
for (frame, click) in clicks.into_iter().enumerate() {
let mut ui = Ui { clicked: click, drawn: Vec::new() };
draw(&mut count, &mut ui);
println!("frame {frame}: {}", ui.drawn.join(" "));
}
}

Output:

frame 0: Count: 0 [+] [Reset]
frame 1: Count: 0 [+] [Reset]
frame 2: Count: 1 [+] [Reset]
frame 3: Count: 2 [+] [Reset]
frame 4: Count: 2 [+] [Reset]
frame 5: Count: 0 [+] [Reset]
▶ Try it Yourself

Look at frame 1: you click “+”, but the label still says 0. The label was drawn before the button noticed the click. The new value shows up one frame later. Real egui handles this for you: any click or key press makes it draw the next frame straight away, so you never see the old number.

eframe is egui’s official “app framework”: it opens the window and runs egui inside it. Create a project and add it:

Terminal window
cargo new counter
cd counter
cargo add eframe@0.37
[dependencies]
eframe = "0.37"

Replace src/main.rs with this:

use eframe::egui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 160.0]),
..Default::default()
};
eframe::run_native(
"Counter",
options,
Box::new(|_cc| Ok(Box::<CounterApp>::default())),
)
}
#[derive(Default)]
struct CounterApp {
count: i32,
}
impl eframe::App for CounterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading(format!("Count: {}", self.count));
ui.horizontal(|ui| {
if ui.button("-").clicked() {
self.count -= 1;
}
if ui.button("+").clicked() {
self.count += 1;
}
if ui.button("Reset").clicked() {
self.count = 0;
}
});
});
}
}

Run it with cargo run. The first build downloads and compiles a few hundred crates, so give it a few minutes. Later builds are fast. A small window opens with “Count: 0” and three buttons.

How it fits together:

  • CounterApp is your state. #[derive(Default)] starts count at 0.
  • run_native opens the window. The closure creates your app once, inside a Box (see Smart Pointers).
  • eframe calls ui for every frame. CentralPanel fills the window, and ui.horizontal puts the three buttons in a row.
  • Each if ui.button(..).clicked() is the immediate-mode pattern from the section above: draw the button, check it, change the state.

This is also why you must keep state in self, never in a local variable inside ui: a let mut count = 0; there would start at 0 again on every frame.

A Tauri app has two parts. The frontend is a normal web page (HTML, CSS and JavaScript, or a framework like React or Svelte) that runs in the system’s webview. The backend is your Rust code. The frontend calls Rust functions called commands.

The easiest start is the official project generator. It asks for a name and a frontend template (the “vanilla” template, with no framework, is a good first choice):

Terminal window
cargo install create-tauri-app --locked
cargo create-tauri-app

There is also npm create tauri-app@latest if you already use Node.js. Then go into the new folder and start the app with npm install and npm run tauri dev (for the npm templates), or cargo install tauri-cli --version "^2.0.0" --locked and cargo tauri dev (for the Rust-only templates). The tool prints the right commands for your choice.

The generated src-tauri/src/lib.rs contains your first command:

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

And the frontend (src/main.js in the vanilla template, shortened here) calls it like this:

const { invoke } = window.__TAURI__.core;
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });

What’s going on:

  • #[tauri::command] is an attribute macro (see Procedural Macros). It writes the glue code that turns the JavaScript call into a normal Rust call.
  • The arguments arrive as JSON, so each argument type must implement serde’s Deserialize, and the return type must implement Serialize (see JSON with Serde). In JavaScript, write argument names in camelCase: a Rust parameter invoke_message becomes { invokeMessage: "..." }.
  • generate_handler![greet] registers the command. If you forget to add a new command here, the call from JavaScript fails.
  • Return a Result to report errors: Ok resolves the JavaScript promise, and Err rejects it, so you can handle it with .catch(). For example, fn load(path: String) -> Result<String, String>.
  • main.rs only calls run(). The code lives in lib.rs so the same app can also be built for Android and iOS.

Slint describes the screen in a small declarative language, a bit like HTML and CSS combined. Your Rust code handles the logic. Here is the official hello world. Add slint = "1.18" under [dependencies] and put this in src/main.rs:

slint::slint!{
export component HelloWorld {
Text {
text: "hello world";
color: green;
}
}
}
fn main() {
HelloWorld::new().unwrap().run().unwrap();
}

The slint! macro compiles the markup at build time and creates a Rust type HelloWorld for you. For a real app, the markup goes in its own .slint file. This counter comes from the official Slint Rust template. The file ui/app-window.slint:

import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in-out property <int> counter: 42;
callback request-increase-value();
VerticalBox {
Text {
text: "Counter: \{root.counter}";
}
Button {
text: "Increase value";
clicked => {
root.request-increase-value();
}
}
}
}

And the Rust side in src/main.rs:

slint::include_modules!();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ui = AppWindow::new()?;
let ui_handle = ui.as_weak();
ui.on_request_increase_value(move || {
let ui = ui_handle.unwrap();
ui.set_counter(ui.get_counter() + 1);
});
ui.run()?;
Ok(())
}

Slint is retained mode: the counter property lives in the UI, and the button calls a callback. Slint generates get_counter, set_counter and on_request_increase_value from the markup (dashes become underscores). The closure holds a weak handle (as_weak()) so the window and its own callback don’t keep each other alive forever (see Weak & Cow). For a .slint file you also need slint-build = "1.18" under [build-dependencies] and a one-line build.rs: slint_build::compile("ui/app-window.slint").expect("Slint build failed");.

Iced follows the Elm architecture, named after the Elm language. Every app has four parts:

  1. State: a struct with everything the app remembers.
  2. Messages: an enum of everything that can happen (“the + button was pressed”).
  3. update: takes the state and one message, and changes the state. It’s the only place where changes happen.
  4. view: builds the widgets from the state. Widgets don’t change anything themselves; they only send messages.

Here is the official Iced counter (add iced = "0.14" to [dependencies]):

use iced::widget::{button, column, text, Column};
use iced::Center;
fn main() -> iced::Result {
iced::run(Counter::update, Counter::view)
}
#[derive(Default)]
struct Counter {
value: i64,
}
#[derive(Debug, Clone, Copy)]
enum Message {
Increment,
Decrement,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value -= 1,
}
}
fn view(&self) -> Column<'_, Message> {
column![
button("Increment").on_press(Message::Increment),
text(self.value).size(50),
button("Decrement").on_press(Message::Decrement),
]
.padding(20)
.align_x(Center)
}
}

on_press(Message::Increment) doesn’t run any code. It only says “when clicked, send this message”. Iced then calls update with it and calls view again to show the new state. Because every change goes through one match, it’s easy to see (and test) everything your app can do. It’s a good use of enums and match.

You don’t need Iced to practice the idea. This to-do model has the same four parts. The view returns text instead of widgets, and a list of messages stands in for the user’s typing and clicking:

// 1. The state: everything the app needs to remember.
#[derive(Default)]
struct App {
draft: String,
todos: Vec<Todo>,
}
struct Todo {
title: String,
done: bool,
}
// 2. The messages: everything that can happen.
#[derive(Debug)]
enum Message {
DraftChanged(String),
AddPressed,
Toggle(usize),
ClearDone,
}
impl App {
// 3. update: the ONLY place the state changes.
fn update(&mut self, message: Message) {
match message {
Message::DraftChanged(text) => self.draft = text,
Message::AddPressed => {
let title = self.draft.trim();
if !title.is_empty() {
self.todos.push(Todo { title: title.to_string(), done: false });
}
self.draft.clear();
}
Message::Toggle(index) => {
if let Some(todo) = self.todos.get_mut(index) {
todo.done = !todo.done;
}
}
Message::ClearDone => self.todos.retain(|todo| !todo.done),
}
}
// 4. view: turns the state into what the user sees (here, text).
fn view(&self) -> String {
let mut screen = format!("New: [{}]\n", self.draft);
for (index, todo) in self.todos.iter().enumerate() {
let mark = if todo.done { "x" } else { " " };
screen.push_str(&format!("{index}. [{mark}] {}\n", todo.title));
}
screen
}
}
fn main() {
let mut app = App::default();
// Pretend these came from key presses and button clicks.
let messages = vec![
Message::DraftChanged("Buy milk".to_string()),
Message::AddPressed,
Message::DraftChanged("Call the bank".to_string()),
Message::AddPressed,
Message::DraftChanged("Water plants".to_string()),
Message::Toggle(0),
];
for message in messages {
println!("> {message:?}");
app.update(message);
}
print!("{}", app.view());
app.update(Message::ClearDone);
println!("> after ClearDone:");
print!("{}", app.view());
}

Output:

> DraftChanged("Buy milk")
> AddPressed
> DraftChanged("Call the bank")
> AddPressed
> DraftChanged("Water plants")
> Toggle(0)
New: [Water plants]
0. [x] Buy milk
1. [ ] Call the bank
> after ClearDone:
New: [Water plants]
0. [ ] Call the bank
▶ Try it Yourself

Notice that view never changes anything, and update never prints anything. That split is the whole trick. To turn this into a real Iced app, you would only rewrite view to return widgets (a text_input that sends DraftChanged, a button that sends AddPressed). The state, the messages and update stay exactly the same.

GUI crates talk to your operating system, so you need a bit more than plain Rust. First set up the basics from Rust Tools & Setup. Then:

OS egui/eframe, Iced, Slint Tauri also needs
Windows Nothing extra beyond the MSVC C++ tools WebView2. It’s already part of Windows 10 (version 1803 and later) and Windows 11
macOS Xcode command line tools (xcode-select --install) Nothing more for desktop apps; full Xcode for iOS
Linux (Debian/Ubuntu) eframe’s README lists libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev webkit2gtk: libwebkit2gtk-4.1-dev plus a few more packages from Tauri’s prerequisites page

If a Linux build fails with a message from pkg-config about a missing library, install that library’s -dev package and build again. gtk-rs needs the GTK 4 development files (libgtk-4-dev on Ubuntu).

  • Blocking the UI thread. A download or a long calculation inside egui’s ui function or an Iced update freezes the whole window until it finishes. Do slow work on another thread or as an async task (see Concurrency and Async/Await), and send the result back to the UI as a message.
  • Keeping egui state in local variables. ui runs again on every frame, so a let mut inside it starts fresh every time. Put state in your app struct.
  • A Tauri command that’s never registered. Every command must appear in tauri::generate_handler![...]. Also, in lib.rs, don’t mark commands as pub: the Tauri docs explain this causes an E0255 “defined multiple times” error.
  • Following an old tutorial. With 0.x crates, an example from last year may not compile. Check the version in the tutorial against your Cargo.toml, and look at the crate’s own examples folder for the version you use.

Write an Elm-style counter with Increment, Decrement and Reset messages. The value must never go below 0, even if the user keeps pressing “-”. Run a list of messages through update and print view() after each one.

Hint: use a u32 and saturating_sub(1), which stops at 0 instead of overflowing (see Integer Overflow).

Show solution
#[derive(Default)]
struct Counter {
value: u32,
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value = self.value.saturating_sub(1),
Message::Reset => self.value = 0,
}
}
fn view(&self) -> String {
format!("Count: {}", self.value)
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Decrement,
Message::Decrement,
Message::Increment,
Message::Reset,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

Output:

Increment -> Count: 1
Increment -> Count: 2
Decrement -> Count: 1
Decrement -> Count: 0
Decrement -> Count: 0
Increment -> Count: 1
Reset -> Count: 0
▶ Try it Yourself

The rule “never below zero” lives in exactly one place, update. We format the message name before calling update, because update takes the message by value and moves it.

Extend your counter with an Undo message. Every change remembers the old value, and Undo goes back one step. Undo with no history does nothing. Show how many undo steps are left in view.

Hint: keep a history: Vec<u32> in the state. push the old value before each change, and pop it on Undo.

Show solution
#[derive(Default)]
struct Counter {
value: u32,
history: Vec<u32>, // older values, newest last
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
Undo,
}
impl Counter {
fn update(&mut self, message: Message) {
let new_value = match message {
Message::Increment => self.value + 1,
Message::Decrement => self.value.saturating_sub(1),
Message::Reset => 0,
Message::Undo => {
if let Some(previous) = self.history.pop() {
self.value = previous;
}
return;
}
};
self.history.push(self.value); // remember the old value first
self.value = new_value;
}
fn view(&self) -> String {
format!("Count: {} (undo steps: {})", self.value, self.history.len())
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Increment,
Message::Reset,
Message::Undo,
Message::Undo,
Message::Undo,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

Output:

Increment -> Count: 1 (undo steps: 1)
Increment -> Count: 2 (undo steps: 2)
Decrement -> Count: 1 (undo steps: 3)
Increment -> Count: 2 (undo steps: 4)
Reset -> Count: 0 (undo steps: 5)
Undo -> Count: 2 (undo steps: 4)
Undo -> Count: 1 (undo steps: 3)
Undo -> Count: 2 (undo steps: 2)
▶ Try it Yourself

Undo was easy to add because every change already goes through update. In a real Iced app, you would only add one more button that sends Message::Undo.

  • Rust GUI works well today, but there is no single standard toolkit yet, and several popular crates are still 0.x.
  • Tauri pairs a web frontend with a Rust backend; egui is immediate mode in plain Rust; Slint uses its own markup and also runs on embedded devices; Iced uses the Elm architecture.
  • Retained mode keeps a tree of widgets and calls your callbacks. Immediate mode redraws the UI from your state on every frame, with no callbacks.
  • The Elm architecture splits an app into state, messages, update and view, which makes changes easy to follow and test.
  • Tauri needs WebView2 on Windows and webkit2gtk on Linux. Check Rust Tools & Setup for the basics.

Quick check

1. In egui, why must the counter value live in your app struct and not in a local variable inside ui?

2. In a Tauri app, what does #[tauri::command] do?

3. In the Elm architecture used by Iced, where does the app state change?

4. What does Tauri need on Linux that egui does not?

Score: 0 / 4