Welcome to maud-ui
58 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 maud-ui
cargo add axum tokio --features tokio/full
cargo add tower-http --features tower-http/fs
2. First paint
A minimal axum server that renders a card with a button. Copy this into src/main.rs and run cargo run.
src/main.rs
use axum::{routing::get, Router};
use maud::{html, Markup, DOCTYPE};
use maud_ui::primitives::{button, card};
async fn index() -> Markup {
html! {
(DOCTYPE)
html lang="en" data-theme="dark" {
head {
meta charset="utf-8";
link rel="stylesheet" href="/assets/maud-ui.min.css";
script src="/assets/maud-ui.min.js" defer {}
}
body style="padding: 2rem;" {
(card::render(card::Props {
title: Some("Welcome".into()),
description: Some("You're running maud-ui.".into()),
children: html! {
(button::render(button::Props {
label: "Ship it".into(),
variant: button::Variant::Primary,
..Default::default()
}))
},
..Default::default()
}))
}
}
}
}
#[tokio::main]
async fn main() {
// Serve the bundled CSS + JS from maud-ui's dist/ folder.
let assets = tower_http::services::ServeDir::new(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../maud-ui/dist"), // or wherever your copy lives
);
let app = Router::new()
.route("/", get(index))
.nest_service("/assets", assets);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Open http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
For production you can embed the assets at compile time with include_str! — see the Deployment section below.
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 = include_str!("../../vendor/maud-ui/dist/maud-ui.min.css");
(
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.