maud-ui79 components

Server-rendered UI components for Rust.

79 typed maud components with shadcn Base UI parity. Most need no JavaScript at all — and when you do need it, 15 heavyweight libraries are already wired up.

$cargo add maud-ui
79components
10composed block templates
15JS libraries pre-wired
0node dependencies

01Agent surfaces

Agent surfaces, as first-class primitives

Message, Streaming Cursor, Code Block, Diff and Tool Call. Everything you need to render an LLM conversation from a Rust server — including collapsed tool invocations with arguments and results.

  • message::Role — Assistant, User, System. Each with its own alignment, avatar slot and live-region behaviour.
  • tool_call::Status — Pending, Running, Success, Error. Collapsible argument and result panes.
  • diff::render — unified hunks with line numbers, rendered on the server. No highlighter in the browser.
You14:32

Rename the role field to a typed enum and add a live flag.

Claude14:32

Swapping String for Role now

src/primitives/message.rs
+2−1
1717pub struct Props {
18Removed: pub role: String,
18Added: pub role: Role,
19Added: pub is_live: bool,
1920}

Live output of message::render, tool_call::render, diff::render and streaming_cursor::render.

02Escape hatches

When you do need JavaScript, it's already wired up

15 heavyweight libraries, each with a worked integration page: a maud-ui shell around the widget, the mount and teardown, and the widget's own theme bound to <html data-theme> so it flips when the page does.

03Tokens

One variable. Every component.

Every component reads var(--mui-*) at paint time. Switching a theme is a stylesheet swap — no rebuild, no re-render, no flash.

Toggle the theme in the header: the swatches beside this paragraph are painted from those variables, so they re-paint with the rest of the page.

cssyour-theme.css
:root[data-theme="light"] {
  --mui-bg: #ffffff;
  --mui-bg-card: #f9fafb;
  --mui-text: #09090b;
  --mui-accent: #2563eb;

  /* Decorative edges — cards, dividers. */
  --mui-border: #e4e4e7;
  /* A CONTROL's edge. Held at 3:1 for WCAG 1.4.11;
     a shared border token made unchecked checkboxes
     invisible at 1.27:1. */
  --mui-border-control: #71717a;
}

Painted from live custom properties, not hex literals.

04Blocks

Parts are easy. Composition is the proof.

10 block templates ship pre-composed — an auth screen, a settings page, a full data table, this dashboard. Each is ordinary primitives inside a render(Props) function you can call as-is or fork.

Overview

Last 30 days compared to the previous period.

MRR

$42,310

+12.4%vs last month

New customers

284

+8.1%vs last month

Active sessions

1,429

-3.2%vs last month

Churn

2.1%

-0.4%lower is better

Recent activity

  • Sofia Davis upgraded to the Pro plan2 min ago
  • Mateo Ortega invited 3 teammates14 min ago
  • Jin-Ho Lee published a new workflow37 min ago
  • Amira Khan exported the Q1 revenue report1 hour ago

Live output of blocks::dashboard::stats::preview() — the same function a consumer calls.

05The index

79 components. None of them undocumented.

Registering a component means touching eight files, and nothing but a test can notice when you miss one. So a test does: every primitive here has a module, a tier, a page, an API doc file, an imported stylesheet and a render test — or cargo test goes red.

Write Rust. Ship HTML. Skip the build step.

One crate, no bundler, no node_modules. Add it to an axum handler and you have a working page in a minute.

rustsrc/main.rs
use axum::{routing::get, Router};
use maud::html;
use maud_ui::primitives::{button, card};

async fn index() -> maud::Markup {
    html! {
        (card::render(card::Props {
            title: Some("Create project".into()),
            description: Some("Deploy your new project in one click.".into()),
            footer: Some(button::render(button::Props {
                label: "Create".into(),
                variant: button::Variant::Primary,
                ..Default::default()
            })),
            ..Default::default()
        }))
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(index));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}