The components
The zgui-ui inventory with props, then a button, a dialog and a select taken apart into the elements, signals, contexts and sheets they are made of.
zgui-ui is a library of styled components. It sits on layer L8, it depends on zgui and its three
sibling crates and on nothing else, and it is optional: every component in it is written with the
API the guide already covers.
This page is the inventory with props, and then three components taken apart — a button, a dialog and a select — down to the elements, signals, contexts, portals and sheets each one is made of.
What every component does the same way
Three rules hold across the crate (crates/zgui-ui/src/lib.rs).
Appearance is a variants! table and a style! sheet. Each component declares its axes once.
The table lowers to a class list and to one data- attribute per axis, and the sheet selects on the
attributes. Nothing computes a class name at run time and nothing branches on a variant in Rust.
Interaction state belongs to the style engine. There is no hovered, no focused and no
pressed signal anywhere in the crate. :hover, :focus-visible, :active, :disabled,
:checked, :indeterminate, :placeholder-shown and :invalid are states the engine already
holds. A component that kept a second copy would disagree with the first one on the frame the
pointer leaves.
A caller can always add to the element. Every component takes class and an attribute bundle,
and merges the caller's values after its own, so the caller wins.
use zgui::prelude::*;
use zgui_ui::prelude::*;
view! {
Button(class = "w-full", attr:data-testid = "save", a11y:label = "Save changes") {"Save"}
}Who owns a value
Every component with a value takes three props: value (or checked, pressed, open, index),
a default_…, and on_change (or on_open_change). The type of the first decides ownership.
| What the caller writes | Who owns the value |
|---|---|
| nothing | the component, starting at default_… |
an RwSignal<T, LocalStorage> | both: the component shows it and writes back to it |
Binding::controlled(read, write) | the caller; the control moves only when write moves read |
A read-only Signal at one of these props is a compile error. There is no
From<Signal<…>> for Binding, and crates/zgui-ui-primitives/src/state/binding.rs carries a
compile_fail doctest that asserts it. on_change is told in all three cases, after the binding
has been asked, and observes rather than drives.
on_change, on_open_change and on_select are props, not listeners. Write on_change = f,
never on:change = f. They do not capture and do not bubble.
How to read the tables
Every component below also takes the tail that is left out of the rows:
| Prop | Type | What it does |
|---|---|---|
class | Classes | classes merged after the component's own |
| the bundle | Attrs, declared #[prop(attrs)] | every attribute the caller wrote, replayed onto the component's own element, after its own |
children | Children or ChildrenFn | what is inside |
Reading the prop cells:
Signal<T>meansSignal<T, LocalStorage>. Every one is#[prop(into)], so a plainTand a signal are both accepted.Binding<T>is the ownership prop from the table above.Option<T>props take aT, neverSome(T).- fn in the children column means
ChildrenFn: the child list is built again each time the surface opens, because a surface that was unmounted has to be built a second time. - A row with no children column entry renders no children.
The inventory
The atoms
| Component | Props | Children |
|---|---|---|
Button | variant: ButtonVariant = Default, size: ButtonSize = Md, disabled: Signal<bool> = false, node_ref: Option<NodeRef> | yes |
Badge | variant: BadgeVariant = Default | yes |
Label | control: Option<NodeRef>, node_ref: Option<NodeRef> | yes |
Separator | orientation: SeparatorOrientation = Horizontal, decorative: bool = true | — |
Skeleton | — | — |
Avatar | src: Signal<Option<String>> = None, size: AvatarSize = Md, label: Option<String> | yes |
ButtonVariant is Default, Secondary, Destructive, Outline, Ghost, Link. ButtonSize
is Sm, Md, Lg, Icon. BadgeVariant is Default, Secondary, Destructive, Outline.
AvatarSize is Sm, Md, Lg.
The containers
| Component | Props | Children |
|---|---|---|
Alert | variant: AlertVariant = Default, live: bool = true, icon: bool = true | yes |
AlertTitle, AlertDescription | — | yes |
Card | — | yes |
CardHeader, CardTitle, CardDescription, CardContent, CardFooter | — | yes |
Progress | value: Signal<Option<f64>> = None, max: f64 = 100.0, label: Option<String> | — |
Progress with value = None is the indeterminate case. Alert(live = true) gives the element
role Alert and a polite live region; live = false makes it a Group.
The text fields
| Component | Props | Children |
|---|---|---|
Input, Textarea | value: Binding<String>, default_value: Option<String>, on_change, placeholder: Option<String>, disabled, read_only, required, invalid (all Signal<bool> = false), label: Option<String>, labelled_by: Option<NodeRef>, node_ref | — |
InputOtp | value: Binding<String>, default_value, on_change, on_complete: Option<UnsyncCallback<String>>, length: usize = 6, disabled: Signal<bool> = false, label, labelled_by, node_ref | — |
Input and Textarea render the field element. The text lives in the framework's editing model
over that element, not in the component.
The choices
| Component | Props | Children |
|---|---|---|
Checkbox | checked: Binding<Checked>, default_checked: Checked = No, on_change, disabled: Signal<bool> = false, labelled_by, node_ref | — |
Switch | checked: Binding<bool>, default_checked: bool = false, on_change, disabled, labelled_by, node_ref | — |
Toggle | pressed: Binding<bool>, default_pressed: bool = false, on_change, variant: ToggleVariant = Default, size: ToggleSize = Md, disabled, label, node_ref | yes |
ToggleGroup | value: Binding<Vec<String>>, default_value, on_change, selection: ToggleSelection = Single, disabled, orientation: Orientation = Horizontal, label | yes |
ToggleGroupItem | value: String, variant, size, disabled, label, node_ref | yes |
RadioGroup | value: Binding<String>, default_value, on_change, disabled, orientation: Orientation = Vertical, label | yes |
RadioGroupItem | value: String, disabled, label, labelled_by, node_ref | — |
Slider | value: Binding<f64>, default_value: f64 = 0.0, on_change, min: f64 = 0.0, max: f64 = 100.0, step: f64 = 1.0, disabled, label, labelled_by, node_ref | — |
Checked is No, Yes, Mixed. Mixed sets UiState::INDETERMINATE, which :indeterminate
matches.
The disclosures
| Component | Props | Children |
|---|---|---|
Collapsible | open: Binding<bool>, default_open: bool = false, on_open_change, disabled, node_ref | yes |
CollapsibleTrigger, CollapsibleContent | — | yes |
Accordion | value: Binding<Vec<String>>, default_value, on_change, selection: AccordionSelection = Single, collapsible: bool = true, disabled | yes |
AccordionItem | value: String, disabled | yes |
AccordionTrigger | level: usize = 3 | yes |
AccordionContent | — | yes |
Tabs | value: Binding<String>, default_value: String = "", on_change, orientation: Orientation = Horizontal, activation: TabsActivation = Automatic, label, node_ref | yes |
TabsList | label: Option<String> | yes |
TabsTrigger | value: String, disabled | yes |
TabsContent | value: String, keep_mounted: bool = false | fn |
AccordionTrigger(level) is the heading level a reader is told, so an accordion inside a section
does not claim to be a top-level heading.
Getting about
| Component | Props | Children |
|---|---|---|
Menubar | label: Option<String> | yes |
MenubarMenu | value: String | yes |
MenubarTrigger | — | yes |
MenubarContent | placement: Placement = Placement::BOTTOM | fn |
MenubarItem | on_select, disabled, shortcut: Option<String> | yes |
MenubarLabel, MenubarSeparator | — | yes / — |
NavigationMenu | value: Binding<String>, default_value: String = "", on_change, label: String = "Main", node_ref | yes |
NavigationMenuList | — | yes |
NavigationMenuItem | value: String | yes |
NavigationMenuTrigger | — | yes |
NavigationMenuContent | — | fn |
NavigationMenuLink | active: Signal<bool> = false | yes |
Breadcrumb | label: String = "Breadcrumb" | yes |
BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage | — | yes |
BreadcrumbSeparator | children: Option<Children> | optional |
BreadcrumbEllipsis | label: String = "More" | — |
Pagination | label: String = "Pagination" | yes |
PaginationContent, PaginationItem | — | yes |
PaginationLink | page: Option<usize>, current: Signal<bool> = false | yes |
PaginationPrevious, PaginationNext | disabled: Signal<bool> = false, label: String | — |
PaginationEllipsis | label: String = "More pages" | — |
SidebarProvider | open: Binding<bool>, default_open: bool = true, on_open_change, side: SidebarSide = Left, collapse: SidebarCollapse = Icon, shortcut: bool = true | yes |
Sidebar | label: String = "Sidebar", children: Option<Children> | optional |
SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarInset | — | yes |
SidebarMenu, SidebarMenuItem | — | yes |
SidebarMenuButton | active: Signal<bool> = false, disabled, label | yes |
SidebarTrigger | label: String = "Toggle sidebar" | — |
page_window(current, pages, slots) -> Vec<Slot> in zgui_ui::pagination computes which page
numbers and which gaps to draw. Slot is Page(usize) or Gap.
The surfaces that move
| Component | Props | Children |
|---|---|---|
ScrollArea | orientation: Orientation = Vertical, label, node_ref | yes |
ScrollBar | orientation: Orientation = Vertical | — |
ResizablePanelGroup | direction: Orientation = Horizontal, on_change: Option<UnsyncCallback<Vec<f64>>>, label, node_ref | yes |
ResizablePanel | default_size: f64 = 0.0, min_size: f64 = 0.0, max_size: f64 = 100.0, label, node_ref | yes |
ResizableHandle | step: f64 = 5.0, label | — |
Carousel | index: Binding<usize>, default_index: usize = 0, on_change, orientation: Orientation = Horizontal, wrap: bool = false, label, node_ref | yes |
CarouselContent, CarouselItem | — | yes |
CarouselPrevious, CarouselNext | label: String | — |
Data
| Component | Props | Children |
|---|---|---|
Table | columns: Signal<String> = "1fr", interactive: bool = false, sticky_header: bool = false, rows: Option<Signal<usize>>, columns_count: Option<Signal<usize>>, label, labelled_by, node_ref | yes |
TableCaption, TableHeader, TableBody, TableFooter | node_ref on all but the caption | yes |
TableRow | index: Option<Signal<usize>>, selected: Option<Signal<bool>>, node_ref | yes |
TableHead | index, align: CellAlign = Start, sort: Signal<ColumnSort> = None, node_ref | yes |
TableCell | index, align: CellAlign = Start, header: bool = false, node_ref | yes |
DataTable<T, I> | rows: Signal<Vec<T>>, columns: Vec<Column<T>>, row_id: I, selectable: bool = false, filterable: bool = false, row_match: Option<RowMatch<T>>, page_size: usize = 0, virtualized: bool = false, row_size: f32 = 40.0, body_height: Option<f32>, resizable: bool = true, empty: String = "No results.", label, on_model: Option<UnsyncCallback<DataModel<T>>>, node_ref | — |
ColumnResizer | header: NodeRef, label: String, on_resize: UnsyncCallback<f32>, step: f32 = 8.0 | — |
VirtualList<V, F> | count: Signal<usize>, row_size: f32 = 32.0, overscan: usize = 4, row: F, label, node_ref | — |
Chart | series: Signal<Vec<Series>>, kind: ChartKind = Bar, width: f64 = 480.0, height: f64 = 240.0, ticks: usize = 5, legend: bool = true, label, node_ref | — |
Calendar | value: Binding<Option<Date>>, default_value, on_change, default_month: Option<Date>, today: Option<Date>, available: Option<DateFilter>, disabled, label, node_ref | — |
CalendarDay | date: Date, in_month: bool, today: bool, unavailable: bool = false, on_choose: UnsyncCallback<Date> | — |
DatePicker | value: Binding<Option<Date>>, default_value, on_change, open: Binding<bool>, on_open_change, today, available, placeholder: String = "Pick a date", disabled, label, labelled_by, node_ref | — |
Table and DataTable are different things. Table is presentational: one CSS grid, plus the
roles a reader needs. DataTable<T, I> is generic and model-driven, and sorts, filters, pages,
selects and virtualises through DataModel<T>.
Column<T>::new(...) builds a column, with .sortable_by(…) and .sized(track). VirtualList
builds only the rows in view plus overscan at each edge.
Toasts
| Component | Props | Children |
|---|---|---|
Toaster | corner: ToastCorner = BottomRight, limit: usize = 3, label: String = "Notifications", dismiss_label: String = "Dismiss" | yes |
ToastItem | queued: Queued, dismiss_label: String = "Dismiss" | — |
Toaster wraps the interface rather than sitting beside it, because it publishes the queue as a
context and a context only flows down. use_toaster() -> Option<ToastQueue> reaches it, and
queue.push(Toast::new("Saved")) shows one.
Forms
| Component | Props | Children |
|---|---|---|
Form | on_submit: Option<UnsyncCallback<()>>, label, submit_on_enter: bool = true, node_ref | yes |
FormField | name: String, validate: Option<Validator> | yes |
FormItem, FormLabel, FormDescription | — | yes |
FormMessage | children: Option<Children> | optional |
FormSubmit | variant, size, disabled, node_ref | yes |
The surfaces that take the window over
| Component | Props | Children |
|---|---|---|
Dialog, AlertDialog, Sheet, Drawer | open: Binding<bool>, default_open: bool = false, on_open_change | yes |
DialogContent | dismiss_on_outside_press: Signal<bool> = true, dismiss_on_escape: Signal<bool> = true, dismiss_control: bool = true | fn |
AlertDialogContent | dismiss_on_escape: Signal<bool> = true | fn |
SheetContent | side: SheetSide = Right, dismiss_on_outside_press, dismiss_on_escape | fn |
DrawerContent | handle: bool = true, dismiss_on_outside_press, dismiss_on_escape | fn |
DialogTrigger | variant: ButtonVariant = Default, size: ButtonSize = Md | yes |
DialogClose | variant: ButtonVariant = Ghost, size: ButtonSize = Md | yes |
DialogDismiss, DrawerHandle | — | — |
DialogHeader, DialogTitle, DialogDescription, DialogFooter | — | yes |
AlertDialogTrigger, AlertDialogAction | variant = Default, size = Md | yes |
AlertDialogCancel | variant = Outline, size = Md | yes |
An alert dialog has no dismiss_on_outside_press prop because it does not have the behaviour: a
stray press past a destructive confirmation must not count as an answer.
The surfaces that float beside a control
| Component | Props | Children |
|---|---|---|
Popover, DropdownMenu, ContextMenu, MenuSub | open: Binding<bool>, default_open: bool = false, on_open_change | yes |
Tooltip, HoverCard | the same three, plus delay: Duration, close_delay: Duration | yes |
PopoverContent | placement: Signal<Placement> = Placement::BOTTOM, offset: f32 = 6.0, dismiss_on_outside_press, dismiss_on_escape | fn |
TooltipContent | placement = Placement::TOP, offset: f32 = 6.0 | fn |
HoverCardContent | placement = Placement::BOTTOM, offset: f32 = 8.0 | fn |
DropdownMenuContent | placement = Placement::new(Side::Bottom, Align::Start), offset: f32 = 4.0 | fn |
ContextMenuContent | placement = Placement::new(Side::Bottom, Align::Start) | fn |
MenuSubContent | placement = Placement::new(Side::Right, Align::Start) | fn |
DropdownMenuTrigger | variant, size | yes |
TooltipTrigger, HoverCardTrigger, ContextMenuTrigger, MenuSubTrigger | disabled on the submenu trigger only | yes |
MenuContent | state: OverlayState, placement = Placement::BOTTOM, offset: f32 = 4.0, dismiss_on_outside_press | fn |
MenuItem | disabled, destructive: bool = false, close_on_select: bool = true, on_select, shortcut: Option<String> | yes |
MenuCheckboxItem | checked: Binding<bool>, default_checked, on_change, disabled, close_on_select | yes |
MenuRadioGroup | value: Binding<String>, default_value, on_change, label | yes |
MenuRadioItem | value: String, disabled, close_on_select | yes |
MenuGroup | label: Option<String> | yes |
MenuLabel, MenuShortcut | — | yes |
MenuSeparator | — | — |
MenuTypeahead | — | yes |
A dropdown menu and a context menu differ in what opens them and in nothing else. Both fill their
surface with the menu components in the table above.
The lists one thing is chosen from
| Component | Props | Children |
|---|---|---|
Select | value: Binding<String>, default_value, on_change, open: Binding<bool>, default_open, on_open_change | yes |
SelectTrigger | disabled, label, labelled_by | yes |
SelectValue | placeholder: Option<String> | — |
SelectContent | placement = Placement::new(Side::Bottom, Align::Start), offset: f32 = 4.0 | fn |
SelectItem | value: String, text: Option<String>, disabled | yes |
Combobox | the same six as Select | yes |
ComboboxInput | placeholder, disabled, label, labelled_by | — |
ComboboxContent | placement = Placement::new(Side::Bottom, Align::Start), offset: f32 = 4.0 | fn |
ComboboxItem | value: String, text: Option<String>, disabled, on_select: Option<UnsyncCallback<()>> | fn |
ComboboxEmpty | — | fn |
Command | value: Binding<String>, default_value, on_change | yes |
CommandList, CommandGroup | label: Option<String> | yes |
CommandDialog | open: Binding<bool>, default_open, on_open_change, title: String = "Commands" | fn |
What those are built out of
These are public, and they are what a surface of your own is written from. AnchoredSurface,
ModalSurface, OverlaySurface and OverlayState are exported from the crate root. Elevated,
Confined, Scrim and ListboxCatalogueOf are reachable at their module path, zgui_ui::overlay
or zgui_ui::listbox, and are absent from the prelude.
| Component | Props | Children |
|---|---|---|
ModalSurface | state: OverlayState, role: Role = Dialog, dismiss_on_outside_press, dismiss_on_escape, scrim: bool = true | fn |
AnchoredSurface | state: OverlayState, layer: OverlayLayer = Popover, placement: Signal<Placement> = Placement::BOTTOM, offset: f32 = 4.0, role: Role = GenericContainer, trap: Option<FocusTrapOptions>, dismiss_on_outside_press, dismiss_on_escape | fn |
OverlaySurface | state: OverlayState, role: Role = GenericContainer, modal: bool = false | yes |
Elevated | at: SurfaceElevation | fn |
Confined | trap: Option<FocusTrapOptions> | fn |
Scrim | — | — |
ListboxCatalogueOf | — | fn |
Aliases
Several names are the same component twice. They are pub use re-exports, not wrappers.
| Written as | Actually |
|---|---|
SheetTrigger, DrawerTrigger, PopoverTrigger | DialogTrigger |
SheetClose, DrawerClose, PopoverClose | DialogClose |
AlertDialogTitle, SheetTitle, DrawerTitle | DialogTitle |
AlertDialogHeader, AlertDialogFooter, AlertDialogDescription, and the Sheet/Drawer equivalents | DialogHeader, DialogFooter, DialogDescription |
SelectGroup, SelectLabel, SelectSeparator | MenuGroup, MenuLabel, MenuSeparator |
CommandInput, CommandItem, CommandEmpty | ComboboxInput, ComboboxItem, ComboboxEmpty |
CommandSeparator, CommandShortcut | MenuSeparator, MenuShortcut |
Deconstruction 1: Button
The whole component, from crates/zgui-ui/src/button/mod.rs:
const SHEET: &str = "zui-button";
#[component]
pub fn Button(
#[prop(default = ButtonVariant::Default)] variant: ButtonVariant,
#[prop(default = ButtonSize::Md)] size: ButtonSize,
#[prop(into, default = Signal::stored_local(false))] disabled: Signal<bool, LocalStorage>,
#[prop(optional)] node_ref: Option<NodeRef>,
#[prop(into, optional)] class: Classes,
#[prop(attrs)] attrs: Attrs,
children: Children,
) -> impl IntoView {
install_stylesheet(SHEET, ButtonStyle::CSS);
let element = node_ref.unwrap_or_default();
let variants = ButtonVariants { variant, size };
let own = variant_attrs(variants.classes(), variants.data_attributes())
.state(UiState::DISABLED, move || disabled.get())
.a11y_from(A11yBinding::new(Role::Button).disabled(move || disabled.get()));
view! {
control(class = ButtonStyle::CLASS, node_ref = element, tabindex = Focus::Sequential,
{..own}, {..attrs}, class = class) {
{children.into_view_once()}
}
}
}What it is made of:
| Ingredient | What it is |
|---|---|
| elements | one control |
| signals of its own | none |
| contexts | none |
| portals | none |
| primitives | none |
| CSS | ButtonStyle, a style! sheet installed once under the name zui-button |
The variants! table is the appearance, and its output is asserted in the crate's own doctest:
let quiet = ButtonVariants { variant: ButtonVariant::Ghost, size: ButtonSize::Sm };
assert_eq!(quiet.class_list(), "zui-button zui-button--ghost zui-button--sm");
assert_eq!(quiet.data_attributes(), [("data-variant", "ghost"), ("data-size", "sm")]);The sheet selects on those data- attributes, and every interaction state is CSS
(crates/zgui-ui/src/button/style.rs):
:scope[data-variant="ghost"] { background-color: transparent; color: var(--zui-color-foreground); }
:scope[data-variant="ghost"]:hover { background-color: var(--zui-color-accent); }
:scope:active { transform: translateY(1px); }
:scope:focus-visible { outline: 2px solid var(--zui-color-ring); outline-offset: 2px; }
:scope:disabled { opacity: 0.5; pointer-events: none; }disabled is written once, as UiState::DISABLED. That one state is what :disabled matches,
what takes the control out of the focus order, what takes it out of the pointer's reach, and what a
reader is told. There is no second copy.
Enter and Space are not handled here. Activating whatever has focus is the framework's own
behaviour, and it arrives as an ordinary click — so on:click, forwarded through the attribute
bundle, is reached by the pointer, by the keyboard and by an accessibility action alike.
To replace it yourself: one control, a variants! table, a sheet and one A11yBinding —
about 110 lines, which is what crates/zgui-ui/src/button/ measures (109 code lines over mod.rs
and style.rs, blank and comment lines excluded).
Deconstruction 2: Dialog
A dialog is an overlay: a surface drawn over the rest of the window, on a paint band of its own, that takes the interaction over until it is answered.
Dialog itself renders no element. It publishes two contexts and returns its children
(crates/zgui-ui/src/dialog/mod.rs):
#[component]
pub fn Dialog(
#[prop(into, optional)] open: Binding<bool>,
#[prop(default = false)] default_open: bool,
#[prop(optional)] on_open_change: Option<UnsyncCallback<bool>>,
children: Children,
) -> impl IntoView {
OverlayState::new(open, default_open, on_open_change).provide();
SurfaceLabels::provide();
view! { {children.into_view_once()} }
}OverlayState is Copy and holds three things every part needs: whether it is open, the trigger,
and the surface.
pub struct OverlayState {
open: Controllable<bool>,
trigger: NodeRef,
content: NodeRef,
}That is why DialogClose, three components down in the footer, closes the dialog without a setter
threaded through anything: it calls OverlayState::current() and then state.close().
SurfaceLabels is the same trick for the other direction — the surface publishes two empty
NodeRef handles on the way down, and DialogTitle and DialogDescription bind themselves to them
on the way up, so the surface can point at a title it did not create.
The composition
DialogContent adds a role, the two label relations and two classes, then hands everything to
ModalSurface, which is the whole of what a dialog, an alert dialog, a sheet and a drawer share
(crates/zgui-ui/src/overlay/modal.rs):
view! {
Portal(layer = {at.layer()}) {
Elevated(at = at) {
Presence(present = open, surface = {state.content()}) {
if move || scrim {
Scrim()
} else {}
DismissableLayer(
layer = {OverlayLayer::Modal},
class = "zui-overlay-layer",
on_dismiss = dismiss,
dismiss_on_outside_press = dismiss_on_outside_press,
dismiss_on_escape = dismiss_on_escape
) {
FocusScope(options = {FocusTrapOptions::MODAL}, class = "zui-overlay-scope") {
OverlaySurface(
state = state,
role = role,
modal = true,
class = {class.get_value()},
{..attrs.get_value()}
) {
{children.get_value().view()}
}
}
}
}
}
}
}Six layers, and the order is the design. A headless primitive is a component from
zgui-ui-primitives that carries one behaviour and no appearance;
Primitives covers them properly.
| Layer | Where it is from | What it answers |
|---|---|---|
Portal(layer = at.layer()) | zgui | which band it is painted on, and it escapes every clipping and transforming ancestor of the trigger |
Elevated(at) | zgui-ui | how deep it is on that band, published as --zui-overlay-depth |
Presence(present, surface) | primitive | whether the exit animation has finished, so the surface unmounts after its fade rather than during it |
DismissableLayer(on_dismiss, …) | primitive | whether a press or an Escape belongs to this surface or to one above it |
FocusScope(options) | primitive | that Tab stays inside, and that focus goes back to the trigger afterwards |
OverlaySurface(state, role, modal) | zgui-ui | the surface element itself, its data-state, and what it means to a reader |
Two details in that block repay reading twice.
The scrim is a sibling of the dismissable layer, not a wrapper around it. A press on the scrim has to count as a press outside the surface. That sibling relationship is the whole of "press the backdrop to close".
Scrim is its own component. It reads use_presence(), and a context is only reachable from a
scope below the one that published it. Written inline it would vanish on the frame the dialog closed
and take its own fade with it. OverlaySurface is its own component for the same reason.
The elements, the signals and the sheets
| Ingredient | What it is |
|---|---|
| elements | box for Elevated, DismissableLayer, FocusScope and the scrim; surface for the panel; control for the corner dismiss; two Buttons for the trigger and the close |
| signals | one Controllable<bool> inside OverlayState; the presence state signal inside Presence |
| contexts | OverlayState, SurfaceLabels, SurfaceElevation, PresenceContext |
| portals | one, on the band that SurfaceElevation::raise(OverlayLayer::Modal) settled on |
| primitives | Presence, DismissableLayer, FocusScope |
| CSS | OverlayStyle under the name zui-overlay, DialogStyle under zui-dialog |
The behaviour boxes are told to leave the layout alone, and the depth becomes a z-index
(crates/zgui-ui/src/overlay/style.rs):
.zui-overlay-layer, .zui-overlay-scope, .zui-overlay-depth { display: contents; }
.zui-overlay-scrim, .zui-overlay-positioner, .zui-surface { z-index: var(--zui-overlay-depth, 0); }The depth has to be stated because mount order is the reverse of what is wanted: a surface opened
from inside another is built while that one's content is, so it reaches the band first and would
otherwise be painted underneath the surface that opened it. SurfaceElevation::raise gives the
higher of the two bands and one more depth:
pub fn raise(wanted: OverlayLayer) -> Self {
match Self::current() {
Some(under) => Self {
band: wanted.max(under.band),
depth: under.depth.saturating_add(1),
},
None => Self { band: wanted, depth: 0 },
}
}So a select opened inside a dialog rises to the modal band and one step above it. You cannot get a popover to draw under the dialog that opened it.
The surface's motion composes two custom properties rather than one transform, because placement
and entry are independent:
.zui-surface {
transform: var(--zui-surface-place, translate(0px, 0px))
var(--zui-surface-motion, translateY(0px) scale(1));
}
.zui-surface[data-state="closed"] { opacity: 0; --zui-surface-motion: translateY(-2px) scale(0.98); }DialogStyle then sets --zui-surface-place: translate(-50%, -50%) to centre the panel on the
window. A shared rule writing transform outright would discard that centring.
To replace it yourself: the open-state context, the label handles, the elevation model, the
scroll lock, the shared surface sheet and the three behaviours it composes — about 1 600 code lines
(crates/zgui-ui/src/dialog/ 296, crates/zgui-ui/src/overlay/ 681, plus Presence 267,
DismissableLayer 317 and FocusScope 54 in zgui-ui-primitives, blank and comment lines
excluded).
Deconstruction 3: Select
A listbox is a list of options with a keyboard that never leaves the control that opened it. The
arrow keys walk the options while the caret stays on the trigger, and the option being walked is
named to a reader through active_descendant rather than by being focused. Moving focus into the
list would take the caret out of the field.
Select renders no element either. It publishes two contexts:
let surface = OverlayState::new(open, default_open, on_open_change).provide();
Listbox::new(surface, value, default_value, on_change).provide();
view! { {children.into_view_once()} }Listbox is the registry, and its fields are the whole state of the control
(crates/zgui-ui/src/listbox/registry.rs):
pub struct Listbox {
collection: Collection, // the options, in tree order
options: RwSignal<BTreeMap<ItemId, ListboxOption>, LocalStorage>,
active: RwSignal<Option<ItemId>, LocalStorage>, // the one the arrows are on
value: Controllable<String>, // the one that is chosen
filter: RwSignal<String, LocalStorage>,
labels: ListboxLabels, // what each value reads as
surface: OverlayState,
dismisses: bool,
}Collection is the primitive that keeps registered items in tree order, recomputed on read.
Registration order would be wrong, because a keyed list rebuilds only the rows that moved.
The four parts
SelectTrigger renders one control and keeps the keyboard the whole time:
control(
node_ref = {state.trigger()},
class = {SelectStyle::CLASS},
class = "zui-select",
tabindex = {Focus::Sequential},
on:click = move |_| {
if !disabled.get_untracked() {
let was_open = state.is_open_untracked();
state.toggle();
if !was_open && let Some(listbox) = listbox {
listbox.highlight_chosen();
}
}
},
on:key_down = on_key_down,
{..own}, {..attrs}, class = class
) {
{children.into_view_once()}
Icon(icon = CHEVRON_DOWN, size = {IconSize::Sm}, class = "zui-select__chevron")
}Its key listener delegates to the registry and stops the event only when the registry claimed the key:
if let Some(listbox) = listbox
&& listbox.handle(&ev.key)
{
ev.prevent_default();
ev.stop_propagation();
}That is what leaves Tab alone. action_for is the whole key map, and it differs in one place
between an open list and a closed one:
let down = Key::Named(NamedKey::ArrowDown);
assert_eq!(action_for(&down, false), Some(ListboxAction::Step(1)));
let enter = Key::Named(NamedKey::Enter);
assert_eq!(action_for(&enter, true), Some(ListboxAction::Choose));
assert_eq!(action_for(&enter, false), None, "a closed list has nothing to choose");The trigger's semantics are one A11yBinding: role ComboBox, has_popup(Listbox),
expanded(…), controls(state.content()) and active_descendant(…) reading the registry's active
node. The chevron is turned over in CSS, from the data-state the trigger already carries:
:scope[data-state="open"] .zui-select__chevron { transform: rotate(180deg); }SelectValue renders a text node whose content is listbox.chosen_text(), or the placeholder,
and toggles UiState::PLACEHOLDER_SHOWN. The text comes from the option, not from a prop, so the
trigger and the list cannot say different things about one value.
SelectContent returns two views side by side, not one wrapper:
let list = view! {
AnchoredSurface(state = state, placement = placement, offset = offset,
role = {Role::ListBox}, {..own}, {..attrs}, class = class) {
{children.get_value().view()}
}
};
let described = view! {
if move || !state.is_open() {
ListboxCatalogueOf {{children.get_value().view()}}
} else {}
};
(list.into_view(), described.into_view())A wrapper would put a box in the caller's layout, and the list contributes nothing where it is written because it is portalled. The second view is the subtle part: while the list is closed the options are built once more, out of sight, purely so they register what their values read as. Without it a select handed a value would show its placeholder over it until somebody had opened the list and closed it again. The two are never mounted together, so nothing registers twice.
SelectItem renders a box — deliberately not focusable — with data-value, data-state,
data-active, data-disabled, UiState::CHECKED, UiState::DISABLED, an A11yBinding of role
ListBoxOption, a click that chooses and a pointer_enter that highlights. The highlight is
data-active, not :hover and not :focus:
.zui-select__item[data-active="true"] {
background-color: var(--zui-color-accent);
color: var(--zui-color-accent-foreground);
}:focus would match nothing here. The caret never leaves the trigger, so the walked option has to
be said out loud.
The list surface
AnchoredSurface is the non-modal twin of ModalSurface: the same behaviours, in the same order,
with a positioner innermost and the focus trap optional.
- Portal(layer)
- Elevated(at)
- Presence(present, surface)
- DismissableLayer
layer=Modal · exclude=state.trigger()- Popper
anchor · placement · offset · zui-overlay-positioner- Confined(trap)
- OverlaySurface
state · role · modal=trap.is_some()
Popper is the primitive that places a floating surface: it measures the anchor, the surface and
the window, solves place, then flip, then shift, and writes data-side and data-align for where
it actually went. exclude = state.trigger() is load-bearing. Without it a press on the trigger
counts as outside the surface, the layer dismisses, the press becomes a click, and the trigger
opens again what it had closed.
| Ingredient | What it is |
|---|---|
| elements | control for the trigger, text for the value, box per option and per indicator, vector through each Icon, surface for the list |
| signals | Controllable<String> for the choice, Controllable<bool> for open, plus options, active and filter in the registry |
| contexts | OverlayState, Listbox, SurfaceElevation, PresenceContext |
| portals | one, floor OverlayLayer::Popover, raised to whatever it was opened inside |
| primitives | Popper, Presence, DismissableLayer, Collection |
| CSS | SelectStyle under zui-select, plus the shared OverlayStyle |
To replace it yourself: the registry, the key map, the closed-list catalogue, the trigger and
the anchored surface — about 1 100 code lines beyond the overlay machinery the dialog already
needed (crates/zgui-ui/src/select/ 344 and crates/zgui-ui/src/listbox/ 448, plus Popper at 349
in zgui-ui-primitives, blank and comment lines excluded).
What is settled and what is not
Changing· The whole library Every crate here is at version = "0.1.0"
(workspace Cargo.toml). No name in the inventory is promised to keep its spelling. The behaviour
below is what the tree does today: 35 test binaries in crates/zgui-ui/tests/, five more in
crates/zgui-ui-primitives/tests/, and device-backed cases under crates/zgui-ui/tests/device/ and
crates/zgui-ui/tests/painted/.
Nothing in the library is a stub. Three components do less than their name suggests, and each limitation is visible in the source:
| Component | Status | What is there, and what is not |
|---|---|---|
Drawer | Partial· partial | A bottom sheet with a scrim, Escape and outside-press dismissal. DrawerHandle is a mark: box(class = "zui-drawer__handle", a11y:hidden = true) with no listener. There is no drag-to-dismiss gesture. |
Chart | Partial· partial | Bar, Line and Area. It is sized by the width and height props in CSS pixels, published as --zui-chart-width and --zui-chart-height, so it does not size itself to its box. Colour reaches the marks through --zgui-fill and --zgui-stroke; the SVG paint longhands are not properties this build generates. |
Calendar, DatePicker | Partial· partial | One date. value is Binding<Option<Date>>, and there is no range type, so a date range is two of them and your own rule about which is which. |
Everything else in the inventory is Stable· complete in the sense that
matters here: it renders, it is operable from the keyboard listed in
crates/zgui-ui/src/lib.rs, and it has tests.
What it costs
Each component calls install_stylesheet(NAME, XStyle::CSS) in its body with a stable name, so
one sheet is installed per component type however many instances exist. Installing under a name
replaces the sheet and keeps its place in the cascade. The repository asserts this by name:
a_second_instance_of_a_component_does_not_install_a_second_sheet
(crates/zgui-ui-tokens/tests/theme.rs).
Beyond that, a component costs what its elements cost. There is no library-wide overhead to account
for: a Button is one control, and one class toggled on one element is measured at 11.34 µs
(docs/performance.md, taken apart in the cost model).
Two costs are worth knowing before you meet them:
- A surface kept mounted while closed, without setting
Popper'sactiveprop to false, is placed again on every frame in which anything scrolls. The default composition unmounts instead, so this only applies to a surface of your own. SelectContentmounts its option bodies a second time while the list is closed. A side effect written in an option body runs twice, once per mount, never concurrently.
Next
Primitives
The eight headless behaviours the deconstructions above kept naming, and how to use them without the styled layer.
Tokens and theming
Every custom property the sheets above read, and how to change them at run time.
Icons
The 22 outlines, how an icon is sized and coloured, and adding one of your own.
Overlays and portals
The same overlay problem, solved by hand from the element vocabulary.