Overview
A Dioxus renderer for HTML and CSS, drawn by Compose
dioxus-compose runs a Dioxus app written the way one is written for the web, with
div, h1, flexbox and a stylesheet, and draws it natively with
Compose Multiplatform through compose-rust. There is no webview and no JVM in what you ship.
The HTML path lays a screen out and produces a drawing plan today, and it does not draw on screen yet. The bridge that hands that plan to the renderer is not built (partial, #43). Every claim on these pages is checked by tests on the Host, and the status page lists what is implemented, partial and planned.
Two ways to write a screen
The crate takes two vocabularies in rsx!, and both are always there. No feature
flag turns either one off, because the owner decided that one crate serves both kinds of app.
| You write | Import | Today |
|---|---|---|
HTML elements and CSS: div, span, input, a stylesheet | dioxus_compose::html::prelude::* | partial laid out and planned in the Host, not drawn yet |
Compose widget names: Column, Text, Button | dioxus_compose::prelude::* | implemented drawn through the renderer today |
The HTML path is the centre of this guide, because most Dioxus apps are written that way. The
widget path has its own pages under Compose widgets. An
rsx! block uses one vocabulary or the other, since each prelude names its own
elements. Putting both on one screen is planned.
What an app looks like
This is the whole of samples/hello: a component and its stylesheet. Nothing in
it is specific to this crate except the import.
use dioxus_compose::html::prelude::*;
use dioxus_hooks::use_signal;
pub const STYLE: &str = r#"
body { margin: 0; font-family: system-ui, sans-serif; font-size: 16px; line-height: 1.5; }
.page { padding: 40px 48px; }
h1 { margin: 0 0 16px; font-size: 32px; line-height: 40px; }
.link { color: #0969da; text-decoration: underline; cursor: pointer; }
"#;
pub fn app() -> Element {
let mut hellos = use_signal(|| 0u32);
rsx! {
main { class: "page",
h1 { id: "title", "Hello, Dioxus" }
p { id: "action",
a {
id: "greet",
class: "link",
href: "#",
onclick: move |event| {
event.prevent_default();
hellos += 1;
},
"Say hello"
}
}
}
}
}
How the HTML path works
Styles and layout are computed inside the Host, the Rust side, by blitz-dom running without a window. The renderer never sees CSS. It gets rectangles, colours and runs of text, which is all Compose needs to draw them.
- Run the app.
HtmlDomruns your component in a DioxusVirtualDomand applies its output to a blitz-dom document. Your root renders into<body>. - Resolve styles. Stylo, the CSS engine from Firefox, resolves your stylesheets and the cascade.
- Lay out. Taffy places blocks, flex and grid containers and tables. Text is
sized by a
TextMeasureryou can supply (see Text). - List what to draw. The result is a
DisplayList: one entry per box, in paint order, with its rectangle, background, border, radii, shadows, clip, opacity, text runs, form field, image and scroll container. - Plan the drawing.
plan_fromturns the list into a tree of drawing elements (boxes placed inside their containers, scroll containers, text, fields, images), anddiffsays what changed between two plans, node by node. - Draw. The renderer draws the plan with Compose. planned This step is the missing bridge (#43).
Events travel the other way. A click at a point reaches the Dioxus handler of the element
under it, and committed text from a field reaches its oninput. Today a test or an
embedder calls these on HtmlDom directly, as the renderer will once it draws.
Rules the HTML path keeps
- JavaScript never runs. A
<script>element is an ordinary element that the user-agent stylesheet hides, so a page cannot execute code you did not write in Rust. - Nothing is fetched. An
<img src>or a CSSurl()is recorded as written. What draws it is your image resolver, so the app decides what it loads and from where. - Fields are uncontrolled. The renderer owns a field's text while the user edits it, and only committed text reaches Rust. Korean, Japanese and Chinese composition never round-trips, because a round trip resets the syllable being typed.
- Scroll position, focus and selection belong to the renderer. They change every frame, and owning them in Rust would put them a frame behind.
How close it is to a browser
The reference is Chromium, which is what VS Code draws with. On 2026-10-03 three regions of the VS Code workbench (the activity bar, the sidebar tree and the editor tabs) were laid out with the Code-OSS CSS at scale 1 on macOS. With text sized as VS Code measured it, the boxes came out as follows.
| Region | Boxes within 1px of VS Code | Largest difference |
|---|---|---|
| Activity bar | 29 of 29 | 0px |
| Sidebar tree | 201 of 203 (99.0%) | 4.00px |
| Editor tabs | 94 of 94 | 0px |
The test is dioxus-compose/tests/fr34_box_tolerance.rs. Text measured by Parley,
the Host's own text engine, did not match: 6 of 22 line breaks fell in a different place. That
is why text will be measured by Compose, the engine that draws it
(partial,
#43).
Why it exists
Desktop apps built on a browser engine are heavy, and an app that stays open all day pays for that every hour. Rust UI toolkits are light, but their text input does not yet reach what Compose does, and an input method that is right 90% of the time is an app you cannot type Korean in. dioxus-compose keeps the Dioxus and CSS that apps are already written in and borrows Compose for the pixels, the text and the input method.
The Compose side is compiled ahead of time into a native library, so there is no JVM in the shipped program. The notepad sample built with the widget path is one 28.76 MB file on macOS, measured on an M1.