Welcome to maud-ui
84 headless, accessible components for Rust web apps. Drop them into any axum/actix/rocket handler — they render to HTML, ship with pre-built CSS and JS, and work without a JavaScript framework.
1. Install
Add maud + maud-ui to your Cargo.toml. If you're wiring up a brand new server, grab axum + tokio too.
Cargo
cargo new my-app
cd my-app
cargo add maud@0.27 --features axum
cargo add maud-ui
cargo add axum tokio --features tokio/full
2. First paint
A complete Axum page with a working dialog and its CSS and JS routes. Copy this into src/main.rs and run cargo run, or run cargo run --example first_paint from this repository.
src/main.rs
use axum::{routing::get, Router};
use maud::{html, Markup, DOCTYPE};
use maud_ui::primitives::{card, dialog};
async fn index() -> Markup {
html! {
(DOCTYPE)
html lang="en" data-theme="dark" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "Welcome — maud-ui" }
link rel="stylesheet" href="/assets/maud-ui.min.css";
script src="/assets/maud-ui.min.js" defer {}
}
body {
main style="max-width:40rem;margin:3rem auto;padding:1.5rem;" {
h1 { "Your first screen" }
(card::render(card::Props {
title: Some("Welcome".into()),
description: Some("You're running maud-ui.".into()),
children: dialog::trigger("welcome", "Open welcome dialog"),
..Default::default()
}))
(dialog::render(dialog::Props {
id: "welcome".into(),
title: "You're ready to build".into(),
children: html! { p { "Close this dialog or press Escape to return to your screen." } },
..Default::default()
}))
}
}
}
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(index))
.route(
"/assets/maud-ui.min.css",
get(|| async { ([("content-type", "text/css")], maud_ui::assets::CSS_MIN) }),
)
.route(
"/assets/maud-ui.min.js",
get(|| async {
(
[("content-type", "application/javascript")],
maud_ui::assets::JS_MIN,
)
}),
);
let address = std::env::var("ADDR").unwrap_or_else(|_| "127.0.0.1:3000".into());
let listener = tokio::net::TcpListener::bind(&address).await.unwrap();
println!("Open http://{address}");
axum::serve(listener, app).await.unwrap();
}
These asset constants are embedded at compile time; no vendored folder or build step is required.
3. Your first component
Every component is a Props struct and a render() function. Start typing — the compiler will guide you.
You write:
button::render(button::Props {
label: "Invite teammate".into(),
variant: button::Variant::Primary,
size: button::Size::Md,
..Default::default()
})You get:
4. Forms + validation
Field wraps label + input + error message with ARIA wiring handled for you.
We'll send the verification link here.
field::render(field::Props {
label: "Work email".into(),
id: "email".into(),
description: Some("We'll send the verification link here.".into()),
required: true,
children: html! {
(input::render(input::Props {
name: "email".into(),
id: "email".into(),
input_type: input::InputType::Email,
placeholder: "you@company.com".into(),
..Default::default()
}))
},
..Default::default()
})5. Theming
Set data-theme on <html> and every component recolors via CSS variables.
Custom palette
[data-theme="dark"] {
--mui-accent: #8b5cf6; /* violet */
--mui-accent-hover: #a78bfa;
--mui-bg: #0c0a1d;
--mui-text: #ede9fe;
}6. JavaScript runtime
Components with interactivity (dialogs, dropdowns, carousels) register behaviors under window.MaudUI. The runtime auto-initializes on DOMContentLoaded and after htmx swaps.
// Dropped into your page via:
// <script src="/assets/maud-ui.min.js" defer></script>
window.MaudUI.init(); // manually re-init (e.g. after a custom swap)
window.MaudUI.init(rootEl); // re-init just a subtree
Tier 1 components (29 of them) render and style correctly with JS disabled. Tier 2 and 3 need the runtime for full keyboard/interaction support.
7. Pair with htmx
maud-ui was designed for htmx flows. Return a fresh Markup fragment from any handler and htmx swaps it in — the runtime re-initializes behaviors on the new nodes automatically.
Trigger a server fragment swap
// Button that asks the server for a fresh table fragment.
(button::render(button::Props {
label: "Refresh results".into(),
variant: button::Variant::Outline,
..Default::default()
}))
// Axum handler returns HTML; htmx replaces #results with it.
async fn results() -> Markup {
html! {
(table::render(table::Props {
headers: vec!["Customer".into(), "MRR".into()],
rows: load_rows().await,
striped: true,
..Default::default()
}))
}
}
8. Deployment
Ship the runtime inside your binary with include_str! so there's nothing to serve from disk.
Embed the bundle at compile time
// Instead of nest_service("/assets", ServeDir::new(...)),
// bake the CSS + JS into your binary:
async fn serve_css() -> impl IntoResponse {
let css = maud_ui::assets::CSS_MIN;
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
css,
)
}
let app = Router::new()
.route("/", get(index))
.route("/assets/maud-ui.min.css", get(serve_css))
.route("/assets/maud-ui.min.js", get(serve_js));
Where to next
Browse the gallery
Every component with code snippets.
Read the API docs
Every Props struct and module, generated from rustdoc.
Pair with Tailwind
Layer order, Preflight, shared tokens.