dioxus-compose guide

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.

rust
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.

Write 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.

rust
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.

AttributeTypeDefaultEffect
weightf32unsetShare of the leftover main-axis space inside a Row or Column.
fill_max_widthboolfalseTake the whole available width.
fill_max_heightboolfalseTake the whole available height.
widthf32 dpunsetA fixed width.
heightf32 dpunsetA fixed height.
paddingf32 dpunsetThe same inset on all four sides, in dp you chose.
padding_roleSpaceRoleunsetThe same inset, in dp the design system chose. Prefer this one.
backgroundPaintunsetA fill behind the widget.
shape_roleShapeRoleunsetCorner rounding the design system picks a radius for.
corner_radiusf32 dpunsetCorner rounding in dp you chose.
border_widthf32 dpunsetHalf of a border. Nothing is drawn until border_color is set too.
border_colorPaintunsetThe other half.
elevationf32 dpunsetResting 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).

Clickable

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.

rust
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.

GroupRolesFor
AccentPrimary, OnPrimary, Secondary, OnSecondary, Tertiary, OnTertiaryThe three accents: what the eye goes to first, second and third.
PageBackground, OnBackgroundThe page itself and the ink on it.
SurfaceSurface, OnSurface, SurfaceVariant, OnSurfaceVariant, SurfaceContainerReading surfaces. SurfaceVariant's ink is the muted one, so it suits captions and labels rather than body text.
ContainerPrimaryContainer, OnPrimaryContainer, SecondaryContainer, OnSecondaryContainer, TertiaryContainer, OnTertiaryContainerQuiet fills that read as relatives of their accent, each carrying its own ink held to the body-text contrast bound.
LineOutline, OutlineVariantBorders and hairlines, strong and quiet.
ErrorError, OnErrorWhat 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

RungFor
DisplayThe largest thing on a screen, used once.
HeadlineA screen's title.
TitleA section's title, a bar's title.
SubtitleThe line under a title.
BodyParagraphs. The default rung.
BodyStrongBody text that has to carry weight.
LabelA button's text, a chip, a field label.
CaptionThe smallest readable rung: timestamps, footnotes.
MonoMonospace: 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

EnumVariantsWhere
ArrangementStart, Center, End, SpaceBetween, SpaceAround, SpaceEvenlyMain axis of Row and Column
AlignmentTopStart, TopCenter, TopEnd, CenterStart, Center, CenterEnd, BottomStart, BottomCenter, BottomEndCross axis of Row and Column; both axes of Box
TextAlignStart, Center, End, JustifyText
TextOverflowClip, Ellipsis, VisibleText, with max_lines
ButtonVariantFilled, Tonal, Outlined, TextButton
MessageDurationShort, LongMessage
WindowSizeClassCompact, Medium, Expandeduse_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.

PropTypeDefaultEffect
arrangementArrangementunsetHow the children are distributed along the main axis.
spacingf32 dpunsetThe gap between children, in dp you chose.
space_roleSpaceRoleunsetThe gap, in dp the design system chose.
alignmentAlignmentunsetCross-axis alignment of the children.
childrenElementrequiredThe stack's contents, in order.
rust
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.

rust
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.

rust
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.

PropTypeDefaultEffect
textString (anything that converts)requiredThe text itself.
type_roleTypeRoleunsetThe rung of the type ladder.
font_sizef32 spunsetOverrides the rung's size.
font_weightu16unsetOverrides the rung's weight, 100 to 900.
line_heightf32unsetOverrides the rung's line height.
letter_spacingf32unsetOverrides the rung's tracking.
colorPaintunsetThe ink. Usually a role.
text_alignTextAlignunsetAlignment inside the text's own box.
max_linesu32unsetTruncate after this many lines.
overflowTextOverflowunsetWhat truncation looks like.
rust
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.

PropTypeDefaultEffect
placeholderString""Shown while the field is empty.
enabledbooltrueWhether it accepts input.
multilineboolfalseWhether Enter can insert a newline.
type_roleTypeRoleunsetThe rung the field's own text is set in. Mono for a path or a snippet.
on_value_changeEventHandler<String>no-opThe current text, as a notification.
on_submitEventHandler<String>no-opA committed value.
on_focus_lostEventHandler<()>no-opThe field lost focus.
on_key_downEventHandler<KeyEvent>no-opA key, with consume() to say you handled it.
rust
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.

PropTypeDefaultEffect
textStringrequiredThe label.
enabledbooltrueWhether it can be pressed.
variantButtonVariantunsetFilled, Tonal, Outlined or Text.
colorPaintunsetThe 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.
iconOption<IconRole>unsetA 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.
backgroundPaintunsetThe fill. Setting it replaces the variant's fill and leaves the shape, border, resting height and padding to the design system.
on_clickEventHandler<()>no-opPressed.
rust
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.

WidgetState propDefaultAlso
Checkboxchecked: boolfalseenabled: bool = true, on_change: EventHandler<bool>
RadioButtonselected: boolfalsethe same two
Switchchecked: boolfalsethe same two
rust
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.

PropTypeDefaultEffect
valuef320.0Where the thumb sits.
minf320.0The low end.
maxf321.0The high end.
stepsu320Stops between the ends. Zero leaves it continuous.
enabledbooltrueWhether it can be dragged.
on_changeEventHandler<f32>no-opThe 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.

PropTypeDefaultEffect
selected_indexusize0Which child is the current choice.
enabledbooltrueWhether it opens.
on_changeEventHandler<usize>no-opThe index chosen.
childrenElementrequiredOne 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.

rust
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.

rust
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.

PropTypeDefaultEffect
item_countusizerequiredHow many items exist in total.
itemCallback<usize, Element>requiredBuilds the item at an index.
key_ofOption<Callback<usize, String>>unsetA stable identity per item. Without it the index is used, which is wrong as soon as the list can be reordered or filtered.
rust
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 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.

WidgetPropTypeDefault
Navigationselected_indexusize, counting destinations rather than children0
childrenElementrequired
NavigationItemtextString""
iconOption<IconRole>unset
colorOption<Paint>unset
enabledbooltrue
on_clickEventHandler<()>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.

rust, in the shape compose-rust/samples/academic uses
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.

WidgetOpen propAlso
Dialogopen: bool = falseon_dismiss: EventHandler<()>, children
Menuexpanded: bool = falseon_dismiss, anchor: Element (required), children
Sheetopen: bool = falseon_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.

rust, in the shape compose-rust/samples/todo uses
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.

A Menu inside a lazy list declares its entries unconditionally

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.

rust
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.

rust
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.

rust
let window = use_window_size();

rsx! {
    if window.is_expanded() {
        Row { Sidebar {} Content {} }
    } else {
        Content {}
    }
}
ClassWidthShape
WindowSizeClass::Compactunder 600 dpPhone-shaped. One column.
WindowSizeClass::Medium600 dp to 840 dpTablet-shaped.
WindowSizeClass::Expanded840 dp and widerDesktop-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.

Reach for the hook last

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.

WidgetWhat the renderer ownsWhat you pass
TextFieldThe whole edit buffer, the selection, the IME compositionNo value at all. You learn about changes through events.
Dialog, Sheet, MenuOpen state, and the enter and exit animationopen or expanded seeds it and carries an outside change.
DropdownWhether the popup is openselected_index only.
Navigation, TabsWhich one is marked, and the move between themselected_index seeds it; a tap fires that child's on_click.
SliderThe position a drag is passing throughvalue seeds it and moves it from outside.
ScrollColumn, ScrollRow, LazyColumn, LazyRowScroll position, and the read-ahead bufferitem_count, item, key_of.
Checkbox, RadioButton, SwitchNothing. These are fully controlledchecked or selected is exactly what is drawn.
DatePicker, TimePickerThe picking surface, the locale, the time zone, the formatvalue, min, max.
every widgetFocus, hover, press feedback, animation progressNothing.

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.