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.
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.
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.
: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.
- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attention Pill
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Code Block
- Collapsible
- Combobox
- Command
- Composer
- Context Menu
- Data Table
- Date Picker
- Dialog
- Diff
- Direction
- Drawer
- Empty State
- Facts List
- Field
- Fieldset
- Form
- Grid
- Gutter Section
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Menu
- Menubar
- Message
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Popover
- Progress
- Radio
- Radio Group
- Resizable
- Scroll Area
- Segmented Control
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Stack
- Status Dot
- Streaming Cursor
- Swatch
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Toggle Group
- Tool Call
- Tooltip
- Turn Progress
- Typography
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.
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();
}