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 honest state of Rust GUI
Section titled “The honest state of Rust GUI”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.
What you can build
Section titled “What you can build”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.
Which crate?
Section titled “Which crate?”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 usesfn ui(&mut self, ui: &mut egui::Ui, ...). - Tauri, Dioxus desktop and gtk-rs depend on system libraries. See Platform prerequisites below.
Immediate mode vs retained mode
Section titled “Immediate mode vs retained mode”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]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.
Your first egui app: a counter
Section titled “Your first egui app: a counter”eframe is egui’s official “app framework”: it opens the window and runs egui inside it. Create a project and add it:
cargo new countercd counter[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:
CounterAppis your state.#[derive(Default)]startscountat 0.run_nativeopens the window. The closure creates your app once, inside aBox(see Smart Pointers).- eframe calls
uifor every frame.CentralPanelfills the window, andui.horizontalputs 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 command: Rust behind a web page
Section titled “A Tauri command: Rust behind a web page”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):
cargo install create-tauri-app --lockedcargo create-tauri-appThere 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 implementSerialize(see JSON with Serde). In JavaScript, write argument names in camelCase: a Rust parameterinvoke_messagebecomes{ invokeMessage: "..." }. generate_handler![greet]registers the command. If you forget to add a new command here, the call from JavaScript fails.- Return a
Resultto report errors:Okresolves the JavaScript promise, andErrrejects it, so you can handle it with.catch(). For example,fn load(path: String) -> Result<String, String>. main.rsonly callsrun(). The code lives inlib.rsso the same app can also be built for Android and iOS.
Slint: the UI in its own markup language
Section titled “Slint: the UI in its own markup language”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 and the Elm architecture
Section titled “Iced and the Elm architecture”Iced follows the Elm architecture, named after the Elm language. Every app has four parts:
- State: a struct with everything the app remembers.
- Messages: an enum of everything that can happen (“the + button was pressed”).
- update: takes the state and one message, and changes the state. It’s the only place where changes happen.
- 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.
Try the Elm architecture in your browser
Section titled “Try the Elm architecture in your browser”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 milk1. [ ] Call the bank> after ClearDone:New: [Water plants]0. [ ] Call the bankNotice 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.
Platform prerequisites
Section titled “Platform prerequisites”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).
Common mistakes
Section titled “Common mistakes”- Blocking the UI thread. A download or a long calculation inside egui’s
uifunction or an Icedupdatefreezes 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.
uiruns again on every frame, so alet mutinside 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, inlib.rs, don’t mark commands aspub: the Tauri docs explain this causes anE0255“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 ownexamplesfolder for the version you use.
Practice
Section titled “Practice”1. A counter that never goes below zero
Section titled “1. A counter that never goes below zero”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: 1Increment -> Count: 2Decrement -> Count: 1Decrement -> Count: 0Decrement -> Count: 0Increment -> Count: 1Reset -> Count: 0The 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.
2. Add an Undo button
Section titled “2. Add an Undo button”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)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,
updateandview, 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
Section titled “Quick check”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
Read more
Section titled “Read more”- Are we GUI yet?: the community overview of Rust GUI crates, with tags like “immediate mode” and “reactive”, and recent news.
- Understanding immediate mode — egui docs: how egui works, with its pros and cons, from the author.
- Calling Rust from the Frontend — Tauri: everything about commands: arguments, return values, errors, async commands and state.
- Slint Rust documentation: the Rust API, the
slint!macro, and how to use.slintfiles withslint-build. - The Iced book: Architecture: a friendly explanation of state, messages, update and view.