dioxus-compose guide

Design systems

Roles, not pixels Planned

A widget set is not a design. The layer that turns Column and Button into something that looks like a macOS app, an Android app or a Windows app is specified in SPEC FR-13 and FR-14. Both are Agreed, down to the wire tags, and neither is implemented. Nothing on this page exists in the crate yet; it is here so you can see the shape your code will take and why the seams fall where they do.

Nothing here is implemented

As of 2026-09-20 the wire schema carries nine modifiers, Empty, Padding, FillMaxWidth, FillMaxHeight, Width, Height, Size, Background and Clickable, and no theme command. There is no Paint, no TypeRole, no DesignSystem and no Theme in the Rust crate. The specification is settled; the code is not written. Every code sample below is illustrative and will not compile.

Roles first, literals as the escape hatch

The governing rule is that a widget emits a role: "this is a title", "this is the surface colour", "this is medium spacing", and the renderer decides what that role looks like. A literal value is always available too, and always means "draw exactly this".

The reason is not taste, it is the frame budget. The Host runs on the renderer's UI thread, so any work it does comes straight out of the per-interaction budget. If the Host resolved tokens, switching to dark mode would re-send a SetProp for every affected property on every node: O(nodes). With the renderer resolving them, the same switch is one SetTheme record and Compose invalidates what it needs to through a CompositionLocal.

There is a second reason. The system colour scheme and the host platform are facts the renderer already knows. For the Host to resolve them it would have to ask, and the boundary is a one-way synchronous call model with a deliberately frozen surface (PR-2). A query would mean a new entry point.

The primitives (FR-13)

Every value has to fit a fixed-layout record, which gives one modifier variant exactly (tag: u16, first: u64, second: u64) of room. That constraint is what kept the list short, and it is why gradients and custom fonts are out.

PrimitiveValuesResolved by
ColorRole 14 semantic slots: Primary, OnPrimary, Secondary, OnSecondary, Surface, OnSurface, SurfaceVariant, OnSurfaceVariant, Background, OnBackground, Outline, OutlineVariant, Error, OnError The design system, per colour scheme
Paint Paint::Role(ColorRole) or Paint::Literal(Color), packed into one u64 Both. Every colour-shaped slot takes a Paint, so colour is described once in the schema rather than twice
TypeRole A nine-rung ladder: Display, Headline, Title, Subtitle, Body, BodyStrong, Label, Caption, Mono The design system; individual axes are overridable per Text
ShapeRole None, ExtraSmall, Small, Medium, Large, Full The design system, 12 dp on Material 3, continuous curvature on HIG, 4 dp on Fluent
SpaceRole None, Xs, Sm, Md, Lg, Xl, Xxl The design system; density is exactly where the three disagree most
Elevation(dp) A single float The design system. Shadow colour, offset and blur are never sent: Material 3 raises the surface tone, HIG draws a wide soft shadow, Fluent layers a shadow with a hairline stroke

A Text that carries only type_role takes the system's size, weight, line height and tracking wholesale. Adding font_size overrides that one axis and nothing else, each override is an independent SetProp, so the change-only transport described in the protocol survives.

illustrative: FR-13 is specified but not built, so this does not compile
// Illustrative. Roles are resolved by whichever design system is active.
rsx! {
    Column {
        padding_role: SpaceRole::Lg,
        space_role: SpaceRole::Md,
        Text { type_role: TypeRole::Title, text: "Conversations" }
        Text {
            type_role: TypeRole::Body,
            color: Paint::Role(ColorRole::OnSurfaceVariant),
            text: "Nothing here yet."
        }
        Button { variant: ButtonVariant::Filled, text: "New chat" }
    }
}

What was left out, and why

ExcludedReason
Gradients, image brushesThey do not fit in (u64, u64), and a resource would have to cross the boundary.
Font families, custom fontsFonts live in the renderer bundle. A name sent from the Host defers "does it exist" to runtime.
Icon and image widgetsThey need an asset protocol, which is a separate requirement rather than a footnote to this one.
Animation specs (duration, easing)Motion is a design-system rule. A Host-supplied curve turns the system into a shell that only receives values.
Blur, HIG vibrancy, ripple configurationPlatform-specific effects; not an axis the three systems share.
A token table sent by the HostIt would reverse the decision that the renderer resolves tokens.

Three systems, one widget tree (FR-14)

A design system here is a pair: a token table and a set of component rules. Phase one is Material 3, Apple HIG and WinUI/Fluent 2. The same rsx! drawn under a different system is meant to look different; that is the feature, not a bug to file.

A second phase adds GNOME 50, KDE Breeze and Deepin for the Linux desktop, and it only starts once phase one draws correctly. Treat those three as planned: the token tables do not exist, and the DesignSystem tags are assigned only for the first three.

Component rules attach to variant properties whose names are design-system-neutral. Button.variant is Filled | Tonal | Outlined | Text, and each system reads it differently: Material 3 gives its filled, tonal, outlined and text buttons with a large corner radius and a ripple; HIG gives an accent button with continuous curvature and no shadow, a grey tonal button, and a plain text button that highlights instead of rippling; Fluent gives accent, standard, standard-with-stroke and subtle at 4 dp with a lighter top border.

The payoff is FR-14's acceptance criterion: adding a fourth design system must not change widgets.rs, the property schema or the wire format. One variant in a Rust enum, one token table and one rule implementation in Kotlin.

Unified and adaptive

The application chooses explicitly, at launch.

illustrative: Theme does not exist in the crate yet
// The same design system on every platform.
LaunchBuilder::new()
    .with_theme(Theme::unified(DesignSystem::Material3))
    .launch(app);

// Follow the host platform. The fallback argument is mandatory.
LaunchBuilder::new()
    .with_theme(Theme::adaptive(DesignSystem::Material3))
    .launch(app);

adaptive is not the default. An app that never calls with_theme gets Theme::unified(DesignSystem::Material3), because a default that quietly looks different on every platform is a surprise rather than a convenience. And because the fallback argument is required, adaptive has no platform it cannot answer for.

PlatformWhat adaptive picks
AndroidMaterial 3
macOS, iOSApple HIG
WindowsWinUI/Fluent 2
Linux (GNOME)GNOME 50 Planned
Linux (KDE)KDE Breeze Planned
Linux (anything else, or undetectable)Deepin Planned
WebWinUI/Fluent 2, switchable to Material 3 by configuration

The Linux rows read the desktop environment once at startup: XDG_CURRENT_DESKTOP first, then DESKTOP_SESSION if that is empty. A value containing GNOME selects GNOME 50, KDE selects Breeze, and anything else, or a failure to tell, selects Deepin. Until those phase-two tables exist, Linux uses the fallback you passed.

The web row is a choice rather than a fact. A browser has no design language of its own, so adaptive has nothing to adapt to; the default is Fluent 2 and an app can configure Material 3 instead. On the web, saying unified outright is the clearer thing to write.

Light and dark are a separate axis from the design system: ColorScheme is Light | Dark | FollowSystem and defaults to FollowSystem. The renderer learns of a system change first and applies it itself. The Host is not involved, the same rule that keeps scroll position and focus on the Kotlin side.

What it adds to the wire

One mutation: SetTheme { design_system, fallback, color_scheme, adaptive }, command tag 9, a 12-byte record of four u16 fields after tag and len, applied to the root rather than to a node. The Host sends it once as the first record of the initial batch, and again only when the application changes the theme. The design system tags are Material3 = 1, AppleHig = 2, Fluent = 3; the colour scheme tags are Light = 1, Dark = 2, FollowSystem = 3.

The primitives are tagged too, and the tags are fixed: every role enum starts at 1, because 0 means "not sent" and can never be a role. The new modifiers append to the existing list as PaddingRole = 9, PaddingEach = 10, Weight = 11, Shape = 12, ShapeRole = 13, Border = 14 and Elevation = 15, and the new text and layout properties append after OnRangeRequested as TypeRole = 13 through Variant = 26. One existing field changes shape rather than tag: Modifier::Background (tag 7) carries a Paint as a u64 instead of a raw argb: u32, so that colour has exactly one representation in the schema. The record length is unchanged and only the schema hash moves.

The token tables themselves are authored in Rust and executed in the renderer. The colour, type, shape and spacing values live in the Rust schema as data, and codegen emits them into Protocol.gen.kt; they are baked into the renderer binary at build time and never cross the boundary at runtime. A renderer implementer fills in the rules, not the numbers.

Implementing a design system is then seven tables: the 14 colour roles in both schemes, the 9 type roles, the 6 shape roles, the 7 spacing roles, the elevation rendering rule, the 4 button variants, and the motion durations and easing. The first four are generated; the last three are the renderer's own work. Once those are filled in, no widget code changes.

A cost worth naming

Because the renderer resolves the tokens, a Rust-side unit test can only assert which role was sent. The actual colours and dimensions are verified in renderer tests. The specification accepts that split rather than pretending it does not exist.