Design systems
Roles, not pixels
A widget set is not a design. Seven design systems turn Column and
Button into something that looks like a Mac, Android or Windows application.
Widgets emit roles, and the renderer resolves them to the active system's tokens.
Roles first, literals as the escape hatch
A widget emits a role: "this is a title", "this is the surface colour", "this is medium spacing". The renderer decides what that role looks like. A literal value is always available too, and it always means "draw exactly this".
The reason is the frame budget. The Host runs on the renderer's UI thread, so any work it does comes out of the interaction's budget. If the Host resolved tokens, switching to dark mode would re-send a property for every affected node. With the renderer resolving them, the same switch is one theme record, and Compose invalidates what it needs to.
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 across the boundary, and the boundary is kept deliberately small.
Seven systems, one widget tree Works
A design system is a pair: a token table (colour, type, shape, spacing) and a set of component
rules (elevation, button variants, motion, materials). DesignSystem has seven:
DesignSystem | Design language |
|---|---|
Material3 | Google's Material 3 |
Cupertino | Apple's Human Interface Guidelines before macOS 26 and iOS 26 |
LiquidGlass | Apple's current language, the one macOS 26 and iOS 26 draw themselves in |
Fluent | Microsoft's WinUI and Fluent 2 |
Gnome | GNOME's Adwaita |
Breeze | KDE's Breeze |
Deepin | Deepin's DDE |
The same tree drawn under a different system is meant to look different. Component
rules read design-neutral variants. ButtonVariant is Filled,
Tonal, Outlined or Text, and each system draws them its
own way: Material 3 with a large radius and a ripple, Apple's systems with continuous
curvature and a highlight, Fluent at 4 dp with a lighter top border.
Adding a design system does not change the widgets, the property schema or the wire format. It is one variant in a Rust enum, one token table and one set of rules in Kotlin.
Unified and adaptive
The application chooses at launch. Theme and DesignSystem exist in
0.0.1, and these values compile today.
use compose_rust::{ColorScheme, DesignSystem, Theme};
// The same design system on every platform.
const UNIFIED: Theme = Theme::unified(DesignSystem::Material3);
// Follow the host platform. The fallback argument is required.
const ADAPTIVE: Theme = Theme::adaptive(DesignSystem::Material3);
// Light and dark are a separate axis. FollowSystem is the default.
const DARK: Theme = UNIFIED.with_color_scheme(ColorScheme::Dark);
A layer that builds the tree passes the theme to LaunchBuilder::with_theme. Not
calling it gives Theme::adaptive(DesignSystem::Material3): the platform's own
look, with Material 3 where the platform has none. The fallback is a required argument, so
there is no platform for which adaptive has nothing to choose.
| Platform | What adaptive picks |
|---|---|
| Android | Material 3 |
| macOS, iOS | Liquid Glass |
| Windows | Fluent |
| Linux, GNOME session | GNOME |
| Linux, KDE session | Breeze |
| Linux, any other session or none detected | Deepin |
| Web | Fluent |
| Anything unrecognised | The fallback you passed |
The Linux rows read XDG_CURRENT_DESKTOP once at startup. The web row is a choice
rather than a fact: a browser has no design language of its own, so on the web saying
unified outright is the clearer thing to write.
ColorScheme is Light, Dark or
FollowSystem. When the system turns dark, the renderer learns first and applies
it itself. The Host is not involved, for the same reason that scroll position and focus stay
in Kotlin.
Application colours Works
An application with a brand of its own lays a Palette over the design system. It
replaces the value of colour roles, one role and one scheme at a time, and nothing else.
Shape, type, spacing, elevation, motion and component rules stay the system's. The same
orange is a Material 3 button under Material 3 and a glass capsule under Liquid Glass.
use compose_rust::{Color, ColorRole, DesignSystem, Palette, Theme};
const EMBER: Palette = Palette::new()
.with(ColorRole::Primary, Color::rgb(0xE8590C), Color::rgb(0xFF8A4C))
.with(ColorRole::Background, Color::rgb(0xFFFBF8), Color::rgb(0x141110));
const THEME: Theme = Theme::adaptive(DesignSystem::Material3).with_palette(&EMBER);
with gives light and dark together; with_light and
with_dark give one. Both schemes travel at once, so following the system into
dark never asks the Host. A role you do not give keeps the system's value.
Palette::check(design_system) lists the pairs your palette leaves short of
contrast; call it from your own tests for all seven systems. In a platform high-contrast mode
the palette is set aside and the system's own colours are drawn.
What crosses the boundary
One SetTheme record, sent as the first record of the initial batch and again only
when the application changes the theme. It names the design system, the fallback, the colour
scheme and whether the theme is adaptive. A palette travels as a short list of role values.
Token tables never cross: they are authored in Rust as data, generated into the renderer at
build time, and resolved there.
Because the renderer resolves the tokens, a Rust-side test can only assert which role was sent. The actual colours and sizes are verified in renderer tests.
Moving to the Compose fork Planned
The design systems' sources have moved out of this repository into the thisisthepy Compose
fork. Splitting them into ordinary Compose libraries under org.thisisthepy.compose.*
is still in progress (#39). After that, any Compose
application can use them, not only compose-rust.
- One component library per system:
org.thisisthepy.compose.material3,.cupertino,.fluent,.liquidglassand the others. org.thisisthepy.compose.adaptiveon top of them, which follows the platform's own system by default.org.thisisthepy.compose.designsystemfor the contract both layers share.
The roles, the Theme value and the wire record stay as they are. What moves is
where the Kotlin that draws them lives.