zgui
Component library

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 writesWho owns the value
nothingthe 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:

PropTypeWhat it does
classClassesclasses merged after the component's own
the bundleAttrs, declared #[prop(attrs)]every attribute the caller wrote, replayed onto the component's own element, after its own
childrenChildren or ChildrenFnwhat is inside

Reading the prop cells:

  • Signal<T> means Signal<T, LocalStorage>. Every one is #[prop(into)], so a plain T and a signal are both accepted.
  • Binding<T> is the ownership prop from the table above.
  • Option<T> props take a T, never Some(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

ComponentPropsChildren
Buttonvariant: ButtonVariant = Default, size: ButtonSize = Md, disabled: Signal<bool> = false, node_ref: Option<NodeRef>yes
Badgevariant: BadgeVariant = Defaultyes
Labelcontrol: Option<NodeRef>, node_ref: Option<NodeRef>yes
Separatororientation: SeparatorOrientation = Horizontal, decorative: bool = true
Skeleton
Avatarsrc: 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

ComponentPropsChildren
Alertvariant: AlertVariant = Default, live: bool = true, icon: bool = trueyes
AlertTitle, AlertDescriptionyes
Cardyes
CardHeader, CardTitle, CardDescription, CardContent, CardFooteryes
Progressvalue: 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

ComponentPropsChildren
Input, Textareavalue: 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
InputOtpvalue: 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

ComponentPropsChildren
Checkboxchecked: Binding<Checked>, default_checked: Checked = No, on_change, disabled: Signal<bool> = false, labelled_by, node_ref
Switchchecked: Binding<bool>, default_checked: bool = false, on_change, disabled, labelled_by, node_ref
Togglepressed: Binding<bool>, default_pressed: bool = false, on_change, variant: ToggleVariant = Default, size: ToggleSize = Md, disabled, label, node_refyes
ToggleGroupvalue: Binding<Vec<String>>, default_value, on_change, selection: ToggleSelection = Single, disabled, orientation: Orientation = Horizontal, labelyes
ToggleGroupItemvalue: String, variant, size, disabled, label, node_refyes
RadioGroupvalue: Binding<String>, default_value, on_change, disabled, orientation: Orientation = Vertical, labelyes
RadioGroupItemvalue: String, disabled, label, labelled_by, node_ref
Slidervalue: 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

ComponentPropsChildren
Collapsibleopen: Binding<bool>, default_open: bool = false, on_open_change, disabled, node_refyes
CollapsibleTrigger, CollapsibleContentyes
Accordionvalue: Binding<Vec<String>>, default_value, on_change, selection: AccordionSelection = Single, collapsible: bool = true, disabledyes
AccordionItemvalue: String, disabledyes
AccordionTriggerlevel: usize = 3yes
AccordionContentyes
Tabsvalue: Binding<String>, default_value: String = "", on_change, orientation: Orientation = Horizontal, activation: TabsActivation = Automatic, label, node_refyes
TabsListlabel: Option<String>yes
TabsTriggervalue: String, disabledyes
TabsContentvalue: String, keep_mounted: bool = falsefn

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

ComponentPropsChildren
Menubarlabel: Option<String>yes
MenubarMenuvalue: Stringyes
MenubarTriggeryes
MenubarContentplacement: Placement = Placement::BOTTOMfn
MenubarItemon_select, disabled, shortcut: Option<String>yes
MenubarLabel, MenubarSeparatoryes / —
NavigationMenuvalue: Binding<String>, default_value: String = "", on_change, label: String = "Main", node_refyes
NavigationMenuListyes
NavigationMenuItemvalue: Stringyes
NavigationMenuTriggeryes
NavigationMenuContentfn
NavigationMenuLinkactive: Signal<bool> = falseyes
Breadcrumblabel: String = "Breadcrumb"yes
BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPageyes
BreadcrumbSeparatorchildren: Option<Children>optional
BreadcrumbEllipsislabel: String = "More"
Paginationlabel: String = "Pagination"yes
PaginationContent, PaginationItemyes
PaginationLinkpage: Option<usize>, current: Signal<bool> = falseyes
PaginationPrevious, PaginationNextdisabled: Signal<bool> = false, label: String
PaginationEllipsislabel: String = "More pages"
SidebarProvideropen: Binding<bool>, default_open: bool = true, on_open_change, side: SidebarSide = Left, collapse: SidebarCollapse = Icon, shortcut: bool = trueyes
Sidebarlabel: String = "Sidebar", children: Option<Children>optional
SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarInsetyes
SidebarMenu, SidebarMenuItemyes
SidebarMenuButtonactive: Signal<bool> = false, disabled, labelyes
SidebarTriggerlabel: 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

ComponentPropsChildren
ScrollAreaorientation: Orientation = Vertical, label, node_refyes
ScrollBarorientation: Orientation = Vertical
ResizablePanelGroupdirection: Orientation = Horizontal, on_change: Option<UnsyncCallback<Vec<f64>>>, label, node_refyes
ResizablePaneldefault_size: f64 = 0.0, min_size: f64 = 0.0, max_size: f64 = 100.0, label, node_refyes
ResizableHandlestep: f64 = 5.0, label
Carouselindex: Binding<usize>, default_index: usize = 0, on_change, orientation: Orientation = Horizontal, wrap: bool = false, label, node_refyes
CarouselContent, CarouselItemyes
CarouselPrevious, CarouselNextlabel: String

Data

ComponentPropsChildren
Tablecolumns: Signal<String> = "1fr", interactive: bool = false, sticky_header: bool = false, rows: Option<Signal<usize>>, columns_count: Option<Signal<usize>>, label, labelled_by, node_refyes
TableCaption, TableHeader, TableBody, TableFooternode_ref on all but the captionyes
TableRowindex: Option<Signal<usize>>, selected: Option<Signal<bool>>, node_refyes
TableHeadindex, align: CellAlign = Start, sort: Signal<ColumnSort> = None, node_refyes
TableCellindex, align: CellAlign = Start, header: bool = false, node_refyes
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
ColumnResizerheader: 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
Chartseries: Signal<Vec<Series>>, kind: ChartKind = Bar, width: f64 = 480.0, height: f64 = 240.0, ticks: usize = 5, legend: bool = true, label, node_ref
Calendarvalue: Binding<Option<Date>>, default_value, on_change, default_month: Option<Date>, today: Option<Date>, available: Option<DateFilter>, disabled, label, node_ref
CalendarDaydate: Date, in_month: bool, today: bool, unavailable: bool = false, on_choose: UnsyncCallback<Date>
DatePickervalue: 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

ComponentPropsChildren
Toastercorner: ToastCorner = BottomRight, limit: usize = 3, label: String = "Notifications", dismiss_label: String = "Dismiss"yes
ToastItemqueued: 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

ComponentPropsChildren
Formon_submit: Option<UnsyncCallback<()>>, label, submit_on_enter: bool = true, node_refyes
FormFieldname: String, validate: Option<Validator>yes
FormItem, FormLabel, FormDescriptionyes
FormMessagechildren: Option<Children>optional
FormSubmitvariant, size, disabled, node_refyes

The surfaces that take the window over

ComponentPropsChildren
Dialog, AlertDialog, Sheet, Draweropen: Binding<bool>, default_open: bool = false, on_open_changeyes
DialogContentdismiss_on_outside_press: Signal<bool> = true, dismiss_on_escape: Signal<bool> = true, dismiss_control: bool = truefn
AlertDialogContentdismiss_on_escape: Signal<bool> = truefn
SheetContentside: SheetSide = Right, dismiss_on_outside_press, dismiss_on_escapefn
DrawerContenthandle: bool = true, dismiss_on_outside_press, dismiss_on_escapefn
DialogTriggervariant: ButtonVariant = Default, size: ButtonSize = Mdyes
DialogClosevariant: ButtonVariant = Ghost, size: ButtonSize = Mdyes
DialogDismiss, DrawerHandle
DialogHeader, DialogTitle, DialogDescription, DialogFooteryes
AlertDialogTrigger, AlertDialogActionvariant = Default, size = Mdyes
AlertDialogCancelvariant = Outline, size = Mdyes

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

ComponentPropsChildren
Popover, DropdownMenu, ContextMenu, MenuSubopen: Binding<bool>, default_open: bool = false, on_open_changeyes
Tooltip, HoverCardthe same three, plus delay: Duration, close_delay: Durationyes
PopoverContentplacement: Signal<Placement> = Placement::BOTTOM, offset: f32 = 6.0, dismiss_on_outside_press, dismiss_on_escapefn
TooltipContentplacement = Placement::TOP, offset: f32 = 6.0fn
HoverCardContentplacement = Placement::BOTTOM, offset: f32 = 8.0fn
DropdownMenuContentplacement = Placement::new(Side::Bottom, Align::Start), offset: f32 = 4.0fn
ContextMenuContentplacement = Placement::new(Side::Bottom, Align::Start)fn
MenuSubContentplacement = Placement::new(Side::Right, Align::Start)fn
DropdownMenuTriggervariant, sizeyes
TooltipTrigger, HoverCardTrigger, ContextMenuTrigger, MenuSubTriggerdisabled on the submenu trigger onlyyes
MenuContentstate: OverlayState, placement = Placement::BOTTOM, offset: f32 = 4.0, dismiss_on_outside_pressfn
MenuItemdisabled, destructive: bool = false, close_on_select: bool = true, on_select, shortcut: Option<String>yes
MenuCheckboxItemchecked: Binding<bool>, default_checked, on_change, disabled, close_on_selectyes
MenuRadioGroupvalue: Binding<String>, default_value, on_change, labelyes
MenuRadioItemvalue: String, disabled, close_on_selectyes
MenuGrouplabel: Option<String>yes
MenuLabel, MenuShortcutyes
MenuSeparator
MenuTypeaheadyes

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

ComponentPropsChildren
Selectvalue: Binding<String>, default_value, on_change, open: Binding<bool>, default_open, on_open_changeyes
SelectTriggerdisabled, label, labelled_byyes
SelectValueplaceholder: Option<String>
SelectContentplacement = Placement::new(Side::Bottom, Align::Start), offset: f32 = 4.0fn
SelectItemvalue: String, text: Option<String>, disabledyes
Comboboxthe same six as Selectyes
ComboboxInputplaceholder, disabled, label, labelled_by
ComboboxContentplacement = Placement::new(Side::Bottom, Align::Start), offset: f32 = 4.0fn
ComboboxItemvalue: String, text: Option<String>, disabled, on_select: Option<UnsyncCallback<()>>fn
ComboboxEmptyfn
Commandvalue: Binding<String>, default_value, on_changeyes
CommandList, CommandGrouplabel: Option<String>yes
CommandDialogopen: 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.

ComponentPropsChildren
ModalSurfacestate: OverlayState, role: Role = Dialog, dismiss_on_outside_press, dismiss_on_escape, scrim: bool = truefn
AnchoredSurfacestate: 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_escapefn
OverlaySurfacestate: OverlayState, role: Role = GenericContainer, modal: bool = falseyes
Elevatedat: SurfaceElevationfn
Confinedtrap: Option<FocusTrapOptions>fn
Scrim
ListboxCatalogueOffn

Aliases

Several names are the same component twice. They are pub use re-exports, not wrappers.

Written asActually
SheetTrigger, DrawerTrigger, PopoverTriggerDialogTrigger
SheetClose, DrawerClose, PopoverCloseDialogClose
AlertDialogTitle, SheetTitle, DrawerTitleDialogTitle
AlertDialogHeader, AlertDialogFooter, AlertDialogDescription, and the Sheet/Drawer equivalentsDialogHeader, DialogFooter, DialogDescription
SelectGroup, SelectLabel, SelectSeparatorMenuGroup, MenuLabel, MenuSeparator
CommandInput, CommandItem, CommandEmptyComboboxInput, ComboboxItem, ComboboxEmpty
CommandSeparator, CommandShortcutMenuSeparator, 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:

IngredientWhat it is
elementsone control
signals of its ownnone
contextsnone
portalsnone
primitivesnone
CSSButtonStyle, 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.

LayerWhere it is fromWhat it answers
Portal(layer = at.layer())zguiwhich band it is painted on, and it escapes every clipping and transforming ancestor of the trigger
Elevated(at)zgui-uihow deep it is on that band, published as --zui-overlay-depth
Presence(present, surface)primitivewhether the exit animation has finished, so the surface unmounts after its fade rather than during it
DismissableLayer(on_dismiss, …)primitivewhether a press or an Escape belongs to this surface or to one above it
FocusScope(options)primitivethat Tab stays inside, and that focus goes back to the trigger afterwards
OverlaySurface(state, role, modal)zgui-uithe 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

IngredientWhat it is
elementsbox for Elevated, DismissableLayer, FocusScope and the scrim; surface for the panel; control for the corner dismiss; two Buttons for the trigger and the close
signalsone Controllable<bool> inside OverlayState; the presence state signal inside Presence
contextsOverlayState, SurfaceLabels, SurfaceElevation, PresenceContext
portalsone, on the band that SurfaceElevation::raise(OverlayLayer::Modal) settled on
primitivesPresence, DismissableLayer, FocusScope
CSSOverlayStyle 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)
        • DismissableLayerlayer=Modal · exclude=state.trigger()
          • Popperanchor · placement · offset · zui-overlay-positioner
            • Confined(trap)
              • OverlaySurfacestate · role · modal=trap.is_some()
How an AnchoredSurface composes its overlay behaviours

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.

IngredientWhat it is
elementscontrol for the trigger, text for the value, box per option and per indicator, vector through each Icon, surface for the list
signalsControllable<String> for the choice, Controllable<bool> for open, plus options, active and filter in the registry
contextsOverlayState, Listbox, SurfaceElevation, PresenceContext
portalsone, floor OverlayLayer::Popover, raised to whatever it was opened inside
primitivesPopper, Presence, DismissableLayer, Collection
CSSSelectStyle 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:

ComponentStatusWhat is there, and what is not
DrawerPartial· partialA 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.
ChartPartial· partialBar, 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, DatePickerPartial· partialOne 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's active prop 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.
  • SelectContent mounts 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

On this page