Reference
Composable reference
Every composable an application can write, with its props, its defaults and what it leaves to
the design system. The list is closed: it is exactly what
dioxus-compose's prelude exports, and the renderer interprets nothing else. A
widget kind it does not recognise produces a ProtocolError event rather than a
crash.
What the prelude gives you
One import brings in the widgets, the roles, the hooks, the signals and the macros.
use dioxus_compose::prelude::*;
That is thirty-three composables, the role enums, Paint,
Color, Theme, DesignSystem,
LaunchBuilder, Message, show_message,
use_window_size, and the Dioxus pieces rsx! expands into. You do
not add dioxus-core, dioxus-hooks or dioxus-signals as
separate dependencies; the prelude re-exports them under the names the macro looks for.
Box is the thirty-fourth, and is the one the prelude leaves out on purpose.
dioxus_compose::Box, not Box
The rsx! expansion in dioxus-core 0.7 refers to an unqualified
Box<T> internally. Exporting the Compose Box through the
glob prelude would shadow std::boxed::Box and break that expansion, so the
prelude deliberately leaves it out. The type is exported from the crate root under the
name Box (its Rust item is called ComposeBox), so write it
qualified inside rsx! until upstream qualifies its own.
rsx! {
dioxus_compose::Box {
fill_max_width: true,
alignment: Alignment::Center,
Text { text: "centred" }
}
}
Modifier attributes
In Compose a Modifier applies to any composable, and the same is true here:
every widget on this page takes all thirteen of these, including
Text and Spacer. They are not listed again per widget.
| Attribute | Type | Default | Effect |
|---|---|---|---|
weight | f32 | unset | Share of the leftover main-axis space inside a Row or Column. |
fill_max_width | bool | false | Take the whole available width. |
fill_max_height | bool | false | Take the whole available height. |
width | f32 dp | unset | A fixed width. |
height | f32 dp | unset | A fixed height. |
padding | f32 dp | unset | The same inset on all four sides, in dp you chose. |
padding_role | SpaceRole | unset | The same inset, in dp the design system chose. Prefer this one. |
background | Paint | unset | A fill behind the widget. |
shape_role | ShapeRole | unset | Corner rounding the design system picks a radius for. |
corner_radius | f32 dp | unset | Corner rounding in dp you chose. |
border_width | f32 dp | unset | Half of a border. Nothing is drawn until border_color is set too. |
border_color | Paint | unset | The other half. |
elevation | f32 dp | unset | Resting height, overriding whatever the design system would have chosen. |
Unset is not zero. A modifier you did not write sends nothing at all rather
than a zero, because no padding and a padding of zero look the same on screen but only one of
them should cost a mutation. Three pairs share one slot each and so cannot both be set
usefully: padding with padding_role, shape_role with
corner_radius, and border_width with border_color
(that last pair is one modifier, and both halves are required).
The wire format carries a Clickable modifier, and the renderer builds it into
the Compose chain, but no typed widget exposes it as a prop. Today a click comes from
Button or NavigationItem. Reaching Clickable means
writing the lowercase element form (card { onclickable: move |_| ... }), which
no sample in this repository does, so treat it as unproven rather than as part of the
widget API.
The role vocabulary
Widgets emit roles, not literals. A role is a name for a job ("the accent
colour", "the heading rung", "a medium corner") and the renderer
resolves it against the active design system's token table. That is what lets one
rsx! tree come out as Material 3 on one machine and Cupertino on another. See
Design systems for how the table is chosen.
Paint and Color
Anywhere a colour is taken, the type is Paint, which is either a role or a
literal ARGB value.
Paint::Role(ColorRole::Primary) // resolved by the design system
Paint::Literal(Color::rgb(0x1b6ef3)) // opaque, 0xRRGGBB
Paint::Literal(Color::argb(0x80ff0000)) // 0xAARRGGBB
A literal is the escape hatch. It survives a design-system change unchanged, which is sometimes what you want (a brand colour) and usually not (a colour that reads on a light page and disappears on a dark one).
ColorRole: 23 roles
Every role that names ink is paired with the surface it reads on. OnPrimary is
the ink for something filled with Primary, and so on down the table.
| Group | Roles | For |
|---|---|---|
| Accent | Primary, OnPrimary, Secondary, OnSecondary, Tertiary, OnTertiary | The three accents: what the eye goes to first, second and third. |
| Page | Background, OnBackground | The page itself and the ink on it. |
| Surface | Surface, OnSurface, SurfaceVariant, OnSurfaceVariant, SurfaceContainer | Reading surfaces. SurfaceVariant's ink is the muted one, so it suits captions and labels rather than body text. |
| Container | PrimaryContainer, OnPrimaryContainer, SecondaryContainer, OnSecondaryContainer, TertiaryContainer, OnTertiaryContainer | Quiet fills that read as relatives of their accent, each carrying its own ink held to the body-text contrast bound. |
| Line | Outline, OutlineVariant | Borders and hairlines, strong and quiet. |
| Error | Error, OnError | What went wrong, and a destructive action's label. |
SurfaceContainer is the one to reach for when a panel has to be
visible against the page. Material 3 gives Surface and
Background the same value on purpose and expresses depth through tonal
containers instead, so a panel filled with Surface on a page of
Background is drawn, in the right colour, and cannot be seen.
SurfaceContainer is the role every supported design system promises to make
visible against the page, and OnSurface is what reads on it.
The three containers are not promised to be distinguishable from one another. Material 3's baseline primary and secondary containers are neighbouring tones of one palette. If you need three fills that separate at a glance, the tertiary pair is the one that does it.
TypeRole: nine rungs
| Rung | For |
|---|---|
Display | The largest thing on a screen, used once. |
Headline | A screen's title. |
Title | A section's title, a bar's title. |
Subtitle | The line under a title. |
Body | Paragraphs. The default rung. |
BodyStrong | Body text that has to carry weight. |
Label | A button's text, a chip, a field label. |
Caption | The smallest readable rung: timestamps, footnotes. |
Mono | Monospace: code, paths, figures that have to line up. |
ShapeRole and SpaceRole
ShapeRole is None, ExtraSmall, Small,
Medium, Large, Full. The radius in dp is the design
system's answer, and Full means a capsule rather than a number.
SpaceRole is None, Xs, Sm,
Md, Lg, Xl, Xxl. It appears as
padding_role on every widget and as space_role on
Row and Column. Density differs per design system, which is the
reason to spell a gap as a role rather than as a number of dp.
IconRole
An icon is addressed by what it is for, never by a platform icon name. There are thirteen
meanings: Back, Forward, Close, Search,
Add, Check, Settings, More,
Home, List, Inbox, Menu,
History. The role is what lets one declaration come out as an SF Symbol under
Cupertino and a Material Symbol under Material 3.
The last two are what a window's own bar needs rather than a destination:
Menu opens what the window has no room to show, and History goes
back over what has already happened. Neither could be said with List, which is a
list of things rather than a way in to more of them.
Layout and text roles
| Enum | Variants | Where |
|---|---|---|
Arrangement | Start, Center, End, SpaceBetween, SpaceAround, SpaceEvenly | Main axis of Row and Column |
Alignment | TopStart, TopCenter, TopEnd, CenterStart, Center, CenterEnd, BottomStart, BottomCenter, BottomEnd | Cross axis of Row and Column; both axes of Box |
TextAlign | Start, Center, End, Justify | Text |
TextOverflow | Clip, Ellipsis, Visible | Text, with max_lines |
ButtonVariant | Filled, Tonal, Outlined, Text | Button |
MessageDuration | Short, Long | Message |
WindowSizeClass | Compact, Medium, Expanded | use_window_size |
Layout
Column and Row
A vertical stack and a horizontal one. The main axis is arranged by
arrangement, the gap between children by spacing or
space_role, and the cross axis by alignment.
| Prop | Type | Default | Effect |
|---|---|---|---|
arrangement | Arrangement | unset | How the children are distributed along the main axis. |
spacing | f32 dp | unset | The gap between children, in dp you chose. |
space_role | SpaceRole | unset | The gap, in dp the design system chose. |
alignment | Alignment | unset | Cross-axis alignment of the children. |
| children | Element | required | The stack's contents, in order. |
rsx! {
Column {
fill_max_width: true,
padding_role: SpaceRole::Md,
space_role: SpaceRole::Sm,
Text { text: "Today", type_role: TypeRole::Title }
Row {
fill_max_width: true,
arrangement: Arrangement::SpaceBetween,
alignment: Alignment::CenterStart,
Text { text: "Steps" }
Text { text: "8,421", type_role: TypeRole::BodyStrong }
}
}
}
The design system decides: what SpaceRole::Md is worth in dp, and the type
ladder Title resolves against.
Box
An overlay container: children are stacked on top of one another and placed by
alignment. It takes alignment and its children, and nothing else of
its own. Write it as dioxus_compose::Box.
ScrollColumn
The whole content with a vertical scroll attached. It has no props of its own beyond the
modifiers and its children, because the scroll position belongs to the
renderer: scrolling never reaches Rust, and there is no property that could set or
read the offset. Use it when the content is bounded. When it is a long list, use
LazyColumn instead, which materialises only what is on
screen.
rsx! {
ScrollColumn {
fill_max_width: true,
fill_max_height: true,
padding_role: SpaceRole::Md,
for paragraph in article() {
Text { key: "{paragraph.id}", text: paragraph.body }
}
}
}
ScrollRow
The same container on its side: the whole content with a horizontal scroll attached. Like
ScrollColumn it takes the modifiers and its children and nothing else, and its
scroll position belongs to the renderer. Use it for a bounded strip, a row of filters or a
handful of cards. For a long horizontal list, use LazyRow.
rsx! {
ScrollRow {
fill_max_width: true,
for album in recent_albums() {
Card { key: "{album.id}", width: 160.0, Text { text: album.title } }
}
}
}
Spacer
A gap. It carries no props of its own at all: its size comes from the modifier attributes
every widget has, which is also how a Compose Spacer is sized.
Spacer { weight: 1.0 } pushes what follows it to the far end of a
Row.
Text and input
Text
type_role alone takes the design system's size, weight, line height and letter
spacing together. Each override replaces exactly one axis and costs one property update, so
changing the font size does not resend the rest of the styling.
| Prop | Type | Default | Effect |
|---|---|---|---|
text | String (anything that converts) | required | The text itself. |
type_role | TypeRole | unset | The rung of the type ladder. |
font_size | f32 sp | unset | Overrides the rung's size. |
font_weight | u16 | unset | Overrides the rung's weight, 100 to 900. |
line_height | f32 | unset | Overrides the rung's line height. |
letter_spacing | f32 | unset | Overrides the rung's tracking. |
color | Paint | unset | The ink. Usually a role. |
text_align | TextAlign | unset | Alignment inside the text's own box. |
max_lines | u32 | unset | Truncate after this many lines. |
overflow | TextOverflow | unset | What truncation looks like. |
rsx! {
Text {
text: entry.summary,
type_role: TypeRole::Body,
color: Paint::Role(ColorRole::OnSurfaceVariant),
max_lines: 2,
overflow: TextOverflow::Ellipsis,
}
}
The design system decides: the whole type ladder, and what colour
OnSurfaceVariant is in light and in dark.
TextField
TextField is uncontrolled and has no value prop.
The edit buffer, the selection and the IME composition state live in the renderer, and Rust
learns about changes through events. This is the central design decision of the project, not
a simplification: round-tripping in-progress text would reset a Hangul syllable mid-assembly.
Widgets in rsx! explains it in full.
| Prop | Type | Default | Effect |
|---|---|---|---|
placeholder | String | "" | Shown while the field is empty. |
enabled | bool | true | Whether it accepts input. |
multiline | bool | false | Whether Enter can insert a newline. |
type_role | TypeRole | unset | The rung the field's own text is set in. Mono for a path or a snippet. |
on_value_change | EventHandler<String> | no-op | The current text, as a notification. |
on_submit | EventHandler<String> | no-op | A committed value. |
on_focus_lost | EventHandler<()> | no-op | The field lost focus. |
on_key_down | EventHandler<KeyEvent> | no-op | A key, with consume() to say you handled it. |
rsx! {
TextField {
placeholder: "Write a message",
multiline: true,
fill_max_width: true,
on_value_change: move |value| draft.set(value),
on_key_down: move |event: KeyEvent| {
if event.key() == Key::Enter && !event.shift_key() {
send();
event.consume();
}
},
}
}
KeyEvent exposes key(), shift_key(),
ctrl_key(), alt_key(), meta_key(),
consume() and consumed(). The Key enum carries one
variant today, Key::Enter. While an IME is composing, key events are not sent to
Rust at all, which is why the condition above is correct rather than merely convenient.
SelectionContainer
Text is not selectable by default, so button labels and destinations are never dragged over
as though the screen were a document. Wrap a block of reading text in
SelectionContainer and every Text inside it, however deep and
however many, becomes one selection: a reader can drag across a paragraph, a list item and a
line of code in a sideways list and copy them with the platform's shortcut or context menu.
Each block arrives on its own line. Buttons and other controls inside stay pressable and are
left out of the selection. It takes no props of its own beyond the modifier attributes, and
nothing about selecting or copying reaches Rust: there is no event and no way to read the
selection, because it is the renderer's state, like scroll position and focus.
Controls
Button
The variant is the seam the design system's component rule attaches to. The same
rsx! draws as a filled Material button, a Cupertino capsule or a Fluent accent
button, and that is correct behaviour rather than a leak.
| Prop | Type | Default | Effect |
|---|---|---|---|
text | String | required | The label. |
enabled | bool | true | Whether it can be pressed. |
variant | ButtonVariant | unset | Filled, Tonal, Outlined or Text. |
color | Paint | unset | The label's colour, for the rare button whose meaning is not its variant's. A destructive action is the case: a plain button whose label is ColorRole::Error. |
icon | Option<IconRole> | unset | A glyph on the button, by meaning rather than by picture. With an empty text the icon is also the button's accessible name, so an icon-only button in a bar is not left nameless. |
background | Paint | unset | The fill. Setting it replaces the variant's fill and leaves the shape, border, resting height and padding to the design system. |
on_click | EventHandler<()> | no-op | Pressed. |
rsx! {
Button {
text: "Delete",
variant: ButtonVariant::Text,
color: Paint::Role(ColorRole::Error),
on_click: move |_| remove(id),
}
}
The design system decides: the shape, the fill, the padding, the press feedback, and whether
a Tonal button is tonal at all.
Checkbox, RadioButton and Switch
All three are controlled: they draw exactly what the prop says and report what the user asked for, so the value the Host holds and the control on screen cannot disagree. Which siblings a radio button excludes is your business, so it carries only whether this one is the chosen one.
| Widget | State prop | Default | Also |
|---|---|---|---|
Checkbox | checked: bool | false | enabled: bool = true, on_change: EventHandler<bool> |
RadioButton | selected: bool | false | the same two |
Switch | checked: bool | false | the same two |
rsx! {
Row {
alignment: Alignment::CenterStart,
space_role: SpaceRole::Sm,
Checkbox {
checked: task.done,
on_change: move |done| set_done(task.id, done),
}
Text { text: task.title }
}
}
Slider
The position a drag is passing through stays in the renderer, so following a finger costs no
boundary call. value seeds that position and moves it when the change came from
somewhere else.
| Prop | Type | Default | Effect |
|---|---|---|---|
value | f32 | 0.0 | Where the thumb sits. |
min | f32 | 0.0 | The low end. |
max | f32 | 1.0 | The high end. |
steps | u32 | 0 | Stops between the ends. Zero leaves it continuous. |
enabled | bool | true | Whether it can be dragged. |
on_change | EventHandler<f32> | no-op | The value the user landed on. |
Dropdown
One choice out of a list. Each child is one option, and the renderer reports the position the user landed on. The popup's own open state is the renderer's.
| Prop | Type | Default | Effect |
|---|---|---|---|
selected_index | usize | 0 | Which child is the current choice. |
enabled | bool | true | Whether it opens. |
on_change | EventHandler<usize> | no-op | The index chosen. |
| children | Element | required | One child per option. |
DatePicker and TimePicker
A date is whole days since 1970-01-01, as i64. A time is minutes since midnight,
as u32. Both take value (required), optional min and
max of the same type, enabled defaulting to true, and
an on_change carrying the same type as value.
There is no prop that asks for a calendar grid, a wheel or a flyout, and there is no format string. Time zone, locale, the first day of the week and the display format stay in the renderer, which is the only side that can read the platform's settings. A format string crossing the boundary would turn "follows the platform" into a claim the renderer cannot keep.
rsx! {
DatePicker {
value: due(),
min: today,
on_change: move |day| due.set(day),
}
TimePicker {
value: remind_at(),
on_change: move |minutes| remind_at.set(minutes),
}
}
ProgressIndicator
value (f32, default 0.0) is how far along;
determinate (bool, default true) says whether
value means anything; circular (bool, default
false) picks between the two forms every supported design system has. There is
no speed, no easing and no track colour: how an indeterminate indicator travels is motion,
and motion is the design system's rule.
Containers and separators
Card, Surface and TopAppBar
Three containers that carry no appearance of their own. Each takes the thirteen modifier attributes and its children, and nothing else.
Cardgroups things. What a card looks like, its fill, its corner and its resting elevation, is the design system's decision. Setelevationwhen you have a reason to override the resting height.Surfaceis a plain background-and-elevation container, for where a card's grouping meaning would be wrong and only the surface is wanted.TopAppBaris the bar across the top of a screen, its children laid out left to right. How the bar is sized, spaced and separated from what is below it is the design system's rule.
rsx! {
TopAppBar {
fill_max_width: true,
Text { text: "Drum school", type_role: TypeRole::Title, weight: 1.0 }
Button { text: "Add", variant: ButtonVariant::Text, on_click: move |_| add() }
}
Card {
fill_max_width: true,
padding_role: SpaceRole::Md,
Text { text: "Four bars, hands only" }
}
}
Divider and Separator
Divider is a line between two things. vertical
(bool, default false) is the only decision you make: the thickness,
the colour and the inset all come from the design system.
Separator is the same line under the name grouped-list code already uses, and is
the one widget that takes no modifier attributes. Its single prop is
color: Option<Paint>, unset by default, for the rare list that rules
itself in something other than the quiet edge.
Tooltip
An explanation attached to its child: text (String, required) plus
children. It is a description as much as a hover popup, because the renderer also exposes the
text to the accessibility tree, so a pointer is not the only way to reach it.
Badge
A small mark that says how many, that there is something new, or one short word.
value (Option<u32>) is a count and text
(Option<String>) a word; with neither it is a dot. color
(Option<ColorRole>) names the role it is filled with and defaults to the
error role; there is no literal colour. With one child the badge is attached to it, an icon or
a destination; with none it stands on its own, the count at the end of a row. Where it sits,
its shape and whether 120 is written out or cut to 99+ are the design system's,
so the same declaration sits on the corner in one system and at the end of the line in
another. What it says is read out as part of the child it is attached to, never on its own.
Windowed lists
LazyColumn and LazyRow are the two axes of one protocol. You
declare how many items there are and how to build one; the renderer asks for the range it can
see, and the component materialises exactly that range and no more.
| Prop | Type | Default | Effect |
|---|---|---|---|
item_count | usize | required | How many items exist in total. |
item | Callback<usize, Element> | required | Builds the item at an index. |
key_of | Option<Callback<usize, String>> | unset | A stable identity per item. Without it the index is used, which is wrong as soon as the list can be reordered or filtered. |
rsx! {
LazyColumn {
fill_max_width: true,
fill_max_height: true,
item_count: tasks.read().len(),
key_of: move |index: usize| tasks.read()[index].id.to_string(),
item: move |index: usize| {
let task = tasks.read()[index].clone();
rsx! { Text { text: task.title } }
},
}
}
There is no buffer prop and no scroll offset. The read-ahead buffer
belongs to the renderer, which owns the scroll position and so is the only side that
knows how far ahead to ask. Widening the range in Rust would break the renderer's placement,
because the first index it receives is what lets it draw a real Compose lazy list of
item_count items. Your data stays in the Host, so scrolling back rebuilds an
identical subtree. Lists and streaming has the whole
story.
Navigation and tabs
Navigation and NavigationItem
A set of destinations with one of them selected, plus the screen they lead to. Children that
are NavigationItem are the destinations; every other child is the content of the
selected screen. Splitting by kind rather than by index means a destination that only exists
under some condition does not force you to keep two lists in step by hand.
Nothing here says bar, rail or drawer. The renderer has already measured the window, so it chooses: a bar across the bottom of a phone-shaped window, a rail down the side of a tablet-shaped one, a drawer standing open on a desktop. There is no prop that could ask for one of them.
| Widget | Prop | Type | Default |
|---|---|---|---|
Navigation | selected_index | usize, counting destinations rather than children | 0 |
| children | Element | required | |
NavigationItem | text | String | "" |
icon | Option<IconRole> | unset | |
color | Option<Paint> | unset | |
enabled | bool | true | |
on_click | EventHandler<()> | no-op |
A destination's label and icon are properties rather than a child tree, because a child tree would fix the arrangement, and the three presentations exist precisely because it differs: a bar stacks the label under the icon, a drawer sets it beside.
Leave color unset and the design system decides what selected looks
like, which is what you want when the application follows the platform. Set it and
that colour is used in both states. Both states on purpose: a destination saying what colour
it is is saying it about itself rather than about half of itself, so a screen that wants the
two apart gives each destination its own colour. What this is for is a design that has no
accent in its bar at all, where asking the active system would put one there.
rsx! {
Navigation {
fill_max_width: true,
fill_max_height: true,
selected_index: destination().index(),
for choice in Destination::STRIP {
NavigationItem {
key: "{choice.label()}",
text: choice.label(),
icon: choice.icon(),
on_click: move |()| destination.set(choice),
}
}
Column { fill_max_width: true, {page} }
}
}
selected_index seeds the renderer's selection and moves it when the change came
from somewhere other than a tap. A tap moves the selection in the renderer and reports it by
firing that destination's own on_click, so switching screens costs one event and
no rebuild of the strip.
Tabs
A row of segments, one child per tab. selected_index (usize,
default 0) works exactly as Navigation's does: it seeds the
renderer's selection, and tapping a tab fires that tab's own on_click rather
than rebuilding the strip.
Overlays and messages
Dialog, Menu and Sheet share one contract:
the open state belongs to the renderer. The boolean you pass seeds it and
carries a change that came from somewhere other than the renderer; the renderer runs the
enter and the exit itself, so the animation never round-trips through Rust. The dismissal
handler fires once when the user asks to close, and it is up to you whether to honour it by
setting the boolean false.
| Widget | Open prop | Also |
|---|---|---|
Dialog | open: bool = false | on_dismiss: EventHandler<()>, children |
Menu | expanded: bool = false | on_dismiss, anchor: Element (required), children |
Sheet | open: bool = false | on_dismiss, children |
Menu's anchor is the widget the popup hangs off, and it travels as
the first child so the renderer can place the popup without Rust knowing any screen
coordinates. Each entry supplies its own on_click, which is what tells you which
one was chosen; the renderer closes the popup itself.
rsx! {
Menu {
expanded: menu_open() == Some(task.id),
on_dismiss: move |_| menu_open.set(None),
anchor: rsx! {
Button {
text: "\u{22ef}",
variant: ButtonVariant::Text,
on_click: move |_| menu_open.set(Some(task.id)),
}
},
Button { text: "Edit", variant: ButtonVariant::Text, on_click: move |_| edit(task.id) }
}
}
For Sheet, which edge it comes in from is the renderer's decision, made from the
width it measured: up from the bottom in a narrow window, in from the side in a wide one.
While the sheet is being dragged about, Rust hears nothing; only a drag that ends in a
dismissal is reported, once.
Building a Menu's children only while it is open would be cheaper, and it does
not terminate: a Menu whose children are a conditional or a loop, inside a
LazyColumn item, recurses until the stack overflows. Declaring the entries
whether the menu is open or not costs one anchor and a few entries per visible row, which
tracks the window rather than the list. compose-rust/samples/todo does it this way and
says so in a comment.
Transient messages
A message is not part of the tree. It has a lifetime rather than a position, and that lifetime belongs to the renderer: how long it stays, where it sits, and what it does when a second one arrives while the first is still up. So a message is posted and forgotten, and it rides out on the batch the current call produces.
show_message("Saved");
Message::new("Task deleted")
.with_action("Undo", move |()| restore())
.with_duration(MessageDuration::Long)
.show();
Nothing here says where the message appears or what it looks like. Material calls it a
snackbar, Apple a transient overlay, GNOME a toast: the design system decides, the same way
it decides what a Dialog looks like. The duration is
MessageDuration::Short or Long, and what those are worth in
milliseconds is the design system's answer too. There is no indefinite duration on purpose:
a message that has to be acknowledged is a Dialog.
Pictures and drawing
Image and Icon
Both take an asset_id: u32, which is required. The bytes reached the renderer
once, through Host::register_asset, and what crosses per frame is the id.
Icon takes a color: Option<Paint> tint as well.
Host::register_icon registers a meaning rather than a picture, so an
id registered that way comes out as whichever artwork the active design system draws for
that IconRole.
Canvas
Pixels the widget vocabulary cannot produce: charts, sparklines, signature pads, rings.
commands: DrawList is required and is its only prop.
The list is a value rather than a callback, because drawing code cannot cross the boundary
into an ahead-of-time compiled renderer. Two equal lists are one attribute comparison, so a
frame that redraws the same chart sends nothing at all. Size comes from the modifier
attributes, and coordinates are dp from the widget's top-left corner. Colour goes through
Paint, so a chart painted in roles follows the design system like everything
else.
let commands = DrawList::builder()
// paint, x, y, width, height, radius, stroke_width. A stroke width of 0 fills.
.round_rect(Paint::Role(ColorRole::SurfaceContainer), 0.0, 0.0, 220.0, 96.0, 12.0, 0.0)
// paint, centre x, centre y, radius, stroke_width
.circle(Paint::Role(ColorRole::Primary), 48.0, 48.0, 24.0, 2.0)
.build();
rsx! {
Canvas { width: 220.0, height: 96.0, commands }
}
DrawListBuilder has line, rect,
round_rect, circle, arc, polyline_ref and
text_at. Check the signatures in
dioxus-compose/src/drawing.rs before writing a long list: the arguments are
positional, and filled-versus-stroked is the trailing stroke width rather than a separate
flag or a second method.
Window size
The UI is authored in Rust but measured by the renderer, so the only way a component can know
how wide the window is, is for the renderer to tell it. use_window_size() is
that channel.
let window = use_window_size();
rsx! {
if window.is_expanded() {
Row { Sidebar {} Content {} }
} else {
Content {}
}
}
| Class | Width | Shape |
|---|---|---|
WindowSizeClass::Compact | under 600 dp | Phone-shaped. One column. |
WindowSizeClass::Medium | 600 dp to 840 dp | Tablet-shaped. |
WindowSizeClass::Expanded | 840 dp and wider | Desktop-shaped. Room for a permanent sidebar. |
The hook returns a WindowSize with width_dp,
height_dp and class, plus is_compact(),
is_medium() and is_expanded(). window_size() is the
same value outside a component.
Only the class crossing is reported. The renderer sends one event when the class changes and nothing in between, so dragging a window from 700 dp to 800 dp re-renders nothing at all, and only the components that called the hook are woken when it does change. The measurements are the ones taken at the moment the class last changed, not a value that follows every pixel of a drag: a size that changed every layout pass would run the VirtualDom every layout pass.
Before the renderer has measured anything the hook reports
Compact at 0 by 0. That is deliberate: a one-column layout is usable at any
width, so the single render that happens before the first measurement is never broken, only
narrower than it needs to be.
Navigation and Sheet already adapt without being asked, and they
do it by moving nodes the renderer already has rather than by rebuilding a tree in Rust.
Use use_window_size() for the layout decisions the widget set cannot make for
you: two columns instead of one, a sidebar that is always open, a grid that changes its
column count.
Where each widget keeps its state
The general rule is that application state is yours and UI-local state is the renderer's. This is the list of places that surprises people, because the widget looks controlled and is not, or looks uncontrolled and is.
| Widget | What the renderer owns | What you pass |
|---|---|---|
TextField | The whole edit buffer, the selection, the IME composition | No value at all. You learn about changes through events. |
Dialog, Sheet, Menu | Open state, and the enter and exit animation | open or expanded seeds it and carries an outside change. |
Dropdown | Whether the popup is open | selected_index only. |
Navigation, Tabs | Which one is marked, and the move between them | selected_index seeds it; a tap fires that child's on_click. |
Slider | The position a drag is passing through | value seeds it and moves it from outside. |
ScrollColumn, ScrollRow, LazyColumn, LazyRow | Scroll position, and the read-ahead buffer | item_count, item, key_of. |
Checkbox, RadioButton, Switch | Nothing. These are fully controlled | checked or selected is exactly what is drawn. |
DatePicker, TimePicker | The picking surface, the locale, the time zone, the format | value, min, max. |
| every widget | Focus, hover, press feedback, animation progress | Nothing. |
Extensions
An extension is a compile-time pair of source files, one Rust and one Kotlin, built into both
sides. It is not a runtime plugin: a deployed renderer has no way to load a new widget. The
crate ships one worked example, LinearProgressIndicator, which takes a single
progress: f32 prop and is exported from the prelude like any other widget.
Adding your own means owning a patched copy of the registry in
dioxus-compose/src/extensions.rs, appending the widget and property tags, and
rendering the new kind in a paired Kotlin renderer. Extension widget tags start at 100 so
they can never collide with a core widget added later.