zgui
Component library

The primitives

What a headless primitive is, the eight in zgui-ui-primitives, and how to style one with your own CSS and no component library.

zgui-ui-primitives is the headless half of the bundled component library: eight interaction behaviours with no appearance at all. It assumes the guide and the library overview. Everything on this page is optional; an application that writes its own behaviours loses nothing.

What a primitive is

A component library has two halves.

One half is appearance: colour, spacing, corner radius, the shadow under a menu, the fade as a dialog closes. That half is CSS, and a style sheet is where it belongs.

The other half is behaviour: where a floating panel goes when the button that opens it sits at the bottom of the window; which key moves what inside a toolbar; which of three stacked surfaces a press outside closes; when content whose exit animation is still running may finally be removed. That half is Rust, and it is the same behaviour for a menu, a select and a date picker.

A primitive is the second half with none of the first. Nothing in this crate draws anything you can see. Each behaviour renders one unstyled element or no element at all, writes data- attributes for a style sheet to select on, and decides nothing about how anything looks. The usual word for that is headless.

The crate's own statement of why it exists:

A component library's hard parts are not its looks. They are collision-aware positioning, focus trapping, exit animations that actually finish, dismissal that knows which surface a press belongs to, item order in a tree that reorders itself, and state that works whether the caller owns it or the component does. Written once, every visible component is thin. Written per component, forty components get them forty subtly different ways.

crates/zgui-ui-primitives/src/lib.rs

It is an ordinary consumer

The crate has exactly one dependency, and it is zgui (crates/zgui-ui-primitives/Cargo.toml). It uses NodeRef and its geometry observations, the typed events, the focus traversal, the overlay bands and the timer heap — the public API this documentation has already described. A test asserts the constraint rather than a comment claiming it: the_sources_name_no_crate_below_the_public_api in crates/zgui-ui-primitives/tests/downstream.rs fails the build if any source names a crate below the public API.

So there is no privileged access to reproduce. Anything a primitive does, your own code can do.

The eight

BehaviourThe question it answersKind
Popperwhere does this floating surface go, and does it still fit?component
FocusScopemay focus leave, and where does it go back to?component
RovingFocuswhat does the next arrow key do?component
Presencehas the exit animation finished yet?component
DismissableLayerdoes this press belong to me, or to something above me?component
Collectionwhat are my items, in the order a reader meets them?value
Controllablewho owns this value — me, or my caller?value
Bindingwhat did the caller tie it to, and what does a click therefore do?value

What each one puts in the document:

PrimitiveThe element it renders
Popperone box, position: fixed, with inline left, top, visibility, plus data-side and data-align
FocusScopeone box, with data-focus-scope set to the empty string
RovingFocusone box, with data-orientation, listening for the arrow keys
DismissableLayerone box, with data-layer
Presencenothing of its own — it mounts and unmounts its children
Collection, Binding, Controllablenothing; they are plain Rust values

The four boxes are real boxes. Each takes part in layout as a block container unless you say otherwise. Where a wrapper must not affect the layout, give it a class whose rule is display: contents: no box is generated and the children are laid out by the grandparent instead. zgui-ui does exactly that to its FocusScope and DismissableLayer wrappers, in one rule (crates/zgui-ui/src/overlay/style.rs). It does not do it to the Popper positioner, which carries the solved position and therefore has to be a box.

What a style sheet selects on

This is the whole appearance contract.

AttributeWritten byValues
data-sidePopper, on its positionertop, right, bottom, left
data-alignPopper, on its positionerstart, center, end
data-orientationRovingFocushorizontal, vertical, both
data-layerDismissableLayercontent, popover, modal, toast
data-focus-scopeFocusScopethe empty string
data-stateyou, from use_presence()open, closed

The last row is deliberate. Presence publishes its state as a context and writes no attribute, because the animation belongs on your surface rather than on a wrapper around it. You bind it. The worked example below shows the one line.

data-side and data-align report where the surface actually went, which may not be where it was asked to go. An arrow that points at the trigger and an entry animation that slides from the right direction both come from those two attributes, with no Rust involved.

Adding the crate

Cargo.toml
[dependencies]
zgui = { path = "../zgui/crates/zgui" }
zgui-ui-primitives = { path = "../zgui/crates/zgui-ui-primitives" }
use zgui_ui_primitives::prelude::*;

The prelude exports every behaviour together with the props type each #[component] generated, because view! names the second to build the first. It is the crate root minus one name, Listening. Every name is also reachable at its own path: zgui_ui_primitives::popper, ::focus, ::presence, ::dismiss, ::collection, ::state.

Popper

A floating surface is one drawn over the rest of the interface rather than in the flow of it: a menu, a tooltip, a select's list. It is placed against an anchor, which is usually the control that opened it. Placement cannot be decided without measuring, because it depends on how much room there is between the anchor and the edge of the window.

#[component]
pub fn Popper(
    /// What the surface is placed against.
    anchor: NodeRef,
    #[prop(into, default = Signal::stored_local(Placement::BOTTOM))]
    placement: Signal<Placement, LocalStorage>,
    /// Cross to the other side of the anchor when there is not enough room.
    #[prop(default = true)] flip: bool,
    /// Slide along the anchor's edge to stay inside the window.
    #[prop(default = true)] shift: bool,
    /// How far off the anchor the surface sits, in CSS pixels.
    #[prop(default = 4.0)] offset: f32,
    /// How close to the window's edge the surface may come, in CSS pixels.
    #[prop(default = 8.0)] padding: f32,
    /// Whether the surface is on screen and therefore worth placing.
    #[prop(into, default = Signal::stored_local(true))] active: Signal<bool, LocalStorage>,
    #[prop(optional)] element_ref: Option<NodeRef>,
    #[prop(into, optional)] class: Classes,
    children: Children,
) -> impl IntoView
RuleDetail
It renders its own positionerA view may only write on nodes it made, so a component handed somebody else's handle could not move it.
It is placed in the frame it opensThe positioner mounts with visibility: hidden; the measurements arrive in the same frame; the offset is written and the visibility cleared before anything is painted.
Three live measurementsThe anchor's border box, the positioner's content size, and the window root's border box — each observed with the _while form and gated on active.
A closed surface watches nothingWhile active reads false, nothing is measured and nothing is solved. Unmounting the surface instead — which Presence does — has the same effect.
Two pixel spacesIt solves in device pixels and divides by the surface's density before writing left and top, because an inline length is read as CSS pixels.

Put it inside a Portal so the surface escapes any clipped or transformed ancestor the trigger lives in. The positioner is placed in the window's own pixels, so it works wherever it is mounted.

The placement types

pub enum Side { Top, Right, Bottom, Left }            // Default: Bottom
pub enum Align { Start, Center, End }                 // Default: Center
pub struct Placement { pub side: Side, pub align: Align }

impl Side {
    pub const ALL: &'static [Self];
    pub const fn opposite(self) -> Self;
    pub const fn is_vertical(self) -> bool;
    pub const fn name(self) -> &'static str;          // the data-side value
}
impl Align { pub const ALL: &'static [Self]; pub const fn name(self) -> &'static str; }
impl Placement {
    pub const BOTTOM: Self;                            // Bottom, Center
    pub const TOP: Self;                               // Top, Center
    pub const fn new(side: Side, align: Align) -> Self;
    pub const fn flipped(self) -> Self;                // opposite side, same alignment
}

The solver on its own

The arithmetic is a public function, so you can place something without using the component.

pub type WindowRect = Rect<DevicePx, Device>;

pub struct PopperOptions {
    pub placement: Placement,
    pub flip: bool,
    pub shift: bool,
    pub offset: f32,   // device pixels here, not CSS pixels
    pub padding: f32,
}

pub struct Solution {
    pub origin: Point<DevicePx, Device>,
    pub placement: Placement,   // where it actually went
    pub overflow: f32,          // how far it still hangs outside; zero when it fits
}

pub fn solve(
    anchor: WindowRect,
    floating: Size<DevicePx, Device>,
    viewport: WindowRect,
    options: &PopperOptions,
) -> Solution;

Three steps, in this order: place, then flip, then shift.

Flip crosses to the opposite side only when the asked side is too short and the opposite side is long enough. A surface that fits nowhere therefore stays where it was asked, instead of flapping between two equally bad choices on every frame.

FocusScope

Focus is which element the keyboard is talking to (see Elements). A focus trap confines the traversal order to one subtree, so Tab cannot leave it. A modal surface without one is a surface a keyboard user tabs straight out of, into controls they cannot see.

#[component]
pub fn FocusScope(
    #[prop(into, default = Signal::stored_local(true))] trapped: Signal<bool, LocalStorage>,
    #[prop(default = FocusTrapOptions::MODAL)] options: FocusTrapOptions,
    #[prop(optional)] element_ref: Option<NodeRef>,
    #[prop(into, optional)] class: Classes,
    children: Children,
) -> impl IntoView

It holds the FocusTrap guard that NodeRef::trap_focus returns. Dropping the guard releases the trap and, when the trap asked for it, puts focus back.

OptionMODALCONFINE_ONLYWhat it does
wraptruetruetabbing past the last control returns to the first
auto_focustruefalsefocus moves inside as the trap goes up
restoretruefalsefocus returns to whatever held it when the trap went up

MODAL is the default and is what a dialog wants. CONFINE_ONLY is what a menu opened from a toolbar wants: it confines the arrow keys without taking the toolbar's focus away.

Traps stack and the innermost wins. Closing an inner dialog hands navigation back to the outer one rather than to the document. Turning trapped off releases the trap without unmounting anything.

RovingFocus

A tab stop is an element that Tab reaches. A toolbar of twelve buttons should be one tab stop, not twelve. A roving tabindex is the pattern that achieves it: exactly one item of the group is sequentially focusable at a time, every other item is focusable only when something focuses it deliberately, and the arrow keys move which is which. It is the behaviour behind toolbars, tab bars, menus, radio groups and listboxes.

#[component]
pub fn RovingFocus(
    #[prop(default = Orientation::Horizontal)] orientation: Orientation,
    #[prop(default = true)] wrap: bool,
    #[prop(optional)] element_ref: Option<NodeRef>,
    #[prop(into, optional)] class: Classes,
    #[prop(attrs)] attrs: Attrs,
    children: Children,
) -> impl IntoView

The #[prop(attrs)] bundle exists because a roving group is always something — a radio group, a toolbar, a tab list — and what it is belongs on the element that carries the keys. Without it the role and the key handling would sit on two elements, and a reader would meet an anonymous container wrapping the thing it was looking for.

KeyEffect
ArrowRight / ArrowLeftone step, in a Horizontal or Both group
ArrowDown / ArrowUpone step, in a Vertical or Both group
Home / Endthe first or last item the keyboard may land on

The keys of the other axis are left alone rather than treated as the same move. A vertical menu that also answered the left and right arrows would swallow the keys a horizontal menubar above it needs, and the submenu would become impossible to leave.

The listener calls prevent_default and stop_propagation only when something moved. An arrow key at the end of a group that does not wrap belongs to whatever is outside the group.

The group and the item

pub enum Orientation { Horizontal, Vertical, Both }   // Default: Horizontal
impl Orientation { pub const fn name(self) -> &'static str; }

pub struct RovingContext;   // Copy
impl RovingContext {
    pub fn collection(&self) -> Collection;
    pub fn active(&self) -> Option<ItemId>;
    pub fn set_active(&self, id: ItemId);
    pub fn orientation(&self) -> Orientation;
    pub fn step(&self, steps: isize) -> bool;         // true when something moved
    pub fn go_to_end(&self, last: bool) -> bool;
    pub fn current() -> Option<Self>;
}

pub struct RovingItem;      // Copy
impl RovingItem {
    pub fn tabindex(&self) -> Signal<Focus, LocalStorage>;
    pub fn is_active(&self) -> bool;
    pub fn activate(&self);
    pub fn id(&self) -> ItemId;
    pub fn group(&self) -> RovingContext;
}

pub fn use_roving_item(node: NodeRef) -> Option<RovingItem>;
pub fn use_roving_item_when(
    node: NodeRef,
    reachable: Signal<bool, LocalStorage>,
) -> Option<RovingItem>;

An item binds tabindex to RovingItem::tabindex, which reads Focus::Sequential for the active item and Focus::Programmatic for the rest. It calls activate when it is focused or pressed, so tabbing away and back returns to the item the user was last on.

None outside a group is an ordinary answer, not a mistake: the same component is usually usable on its own, where it is an ordinary tab stop. The caller falls back to Focus::Sequential.

Until anything has been chosen the tab stop is the first item in tree order, so a group is reachable from the keyboard from the moment it has anything in it.

use_roving_item_when(node, reachable) is disabled but present: the item keeps its place and a reader still meets it, and the arrow keys pass over it. That is the difference between a control that is disabled and one that is not there.

#[component]
fn ToolbarButton(children: Children) -> impl IntoView {
    let node = NodeRef::new();
    let item = use_roving_item(node);
    view! {
        control(
            node_ref = node,
            tabindex = move || item.map_or(Focus::Sequential, |item| item.tabindex().get()),
            on:focus_in = move |_| { if let Some(item) = item { item.activate() } }
        ) {
            {children.into_view_once()}
        }
    }
}

Presence

An exit animation is the animation content plays on its way out. It creates a problem that has nothing to do with animation: content that is unmounted the moment it closes has no chance to play one, and content unmounted after a duration written in Rust needs that number to agree with a number in CSS. The two drift the first time anyone edits the sheet.

Presence guesses nothing. The state goes to closed, the cascade starts whatever the sheet says happens at [data-state="closed"], and the content is unmounted when that animation ends — or in the same frame, when there is no animation to wait for.

#[component]
pub fn Presence(
    #[prop(into)] present: Signal<bool, LocalStorage>,
    /// The caller's element, whose exit animation decides when the content leaves.
    surface: NodeRef,
    children: ChildrenFn,
) -> impl IntoView

pub enum PresenceState { Open, Closed }
impl PresenceState {
    pub const fn name(self) -> &'static str;      // "open" | "closed"
    pub const fn is_leaving(self) -> bool;
}

pub struct PresenceContext;   // Copy
impl PresenceContext {
    pub fn new(state: Signal<PresenceState, LocalStorage>) -> Self;
    pub fn state(&self) -> PresenceState;
    pub fn state_name(&self) -> &'static str;
    pub fn current() -> Option<Self>;
}

pub fn use_presence() -> Option<PresenceContext>;

The surface handle is the caller's element, not one this component made. A wrapper of its own would put a box in the layout that the component author never wrote, and the animation belongs on the surface itself.

children is ChildrenFn because the content is built again every time it comes back.

Four facts worth knowing before you rely on it:

  1. It listens on whichever element the handle names now. Content that goes away and comes back is a new element bound to the same handle. Listeners attached on the first open would stay on the departed node and hear nothing from the second open onwards. Listening::named re-attaches them on every rebind. Listening is public, at zgui_ui_primitives::presence::Listening.
  2. It waits for both ends of both kinds: ANIMATION_END, ANIMATION_CANCEL, TRANSITION_END and TRANSITION_CANCEL. A cancelled animation produces no end, and content waiting for one would stay mounted for ever.
  3. It asks again rather than counting. The unmount happens when the state is leaving and surface.running_animations() is zero, so a second animation started during the first is not cut off.
  4. An exit that has not finished within one second finishes anyway. The constant is EXIT_DEADLINE in crates/zgui-ui-primitives/src/presence/deadline.rs. A modal surface that stays mounted keeps its scrim over the window and its focus trap around a subtree nobody can see, so the window answers nothing for the rest of the session. That is out of all proportion to one dropped animation end.

The one thing the content has to do is bind the state to an attribute. use_presence() reaches the context only from below the Presence, so the surface is its own component. Writing it inline in the parent is the mistake that makes the exit animation vanish.

DismissableLayer

Dismissal is closing an open surface because the user pressed Escape or pressed something outside it. Every floating surface needs it, and none of them should write it: hearing about a press elsewhere in the window means listening on the window's root, deciding whether the press was inside means asking the engine, and deciding whether the press was yours means knowing what else is open.

#[component]
pub fn DismissableLayer(
    /// Told why the layer should close. It closes nothing itself.
    on_dismiss: UnsyncCallback<DismissReason>,
    #[prop(default = OverlayLayer::default())] layer: OverlayLayer,
    #[prop(into, default = Signal::stored_local(true))]
    dismiss_on_outside_press: Signal<bool, LocalStorage>,
    #[prop(into, default = Signal::stored_local(true))]
    dismiss_on_escape: Signal<bool, LocalStorage>,
    /// One element outside the layer that a press on nonetheless belongs to it.
    #[prop(optional)] exclude: Option<NodeRef>,
    #[prop(optional)] element_ref: Option<NodeRef>,
    #[prop(into, optional)] class: Classes,
    children: Children,
) -> impl IntoView

#[non_exhaustive]
pub enum DismissReason { OutsidePress, EscapeKey }
impl DismissReason { pub const fn name(self) -> &'static str; }

It closes nothing. It reports through on_dismiss and the caller decides, which is what makes a confirmation dialog that refuses to close expressible without a second component.

exclude is what a trigger is. A surface anchored to a button sits nowhere near that button in the tree, so a press on the button is a press outside the surface: the layer dismisses, the press goes on to become a click, and the trigger reopens the surface it closed. Naming the trigger in exclude is what stops that.

Both listeners go on the window root, on the capture leg, so the decision is taken before anything in the press's own path runs. A layer that dismissed on the bubble leg would be dismissed by its own trigger's click.

The stack

pub struct LayerId(u64);
impl LayerId { pub const fn new(value: u64) -> Self; pub const fn get(self) -> u64; }

pub struct LayerStack;
impl LayerStack {
    pub fn new() -> Self;
    pub fn current() -> Self;
    pub fn push(&self, band: OverlayLayer, surface: NodeRef) -> LayerId;
    pub fn pop(&self, id: LayerId);
    pub fn set_leaving(&self, id: LayerId, leaving: bool);
    pub fn topmost(&self) -> Option<LayerId>;
    pub fn is_topmost(&self, id: LayerId) -> bool;
    pub fn answering_escape(&self) -> Option<LayerId>;
    pub fn answers_escape(&self, id: LayerId) -> bool;
    pub fn len(&self) -> usize;
    pub fn is_empty(&self) -> bool;
}

Exactly one layer answers, and which one is decided by the overlay band first and by the order they opened second. The band comes first because a toast raised before a dialog is still above it.

The subtle rule is the one that makes nesting work:

EventA layer that has been told to close, and is still playing its exit
a pressstill answers it — it is still on the screen, and a press has a place
Escapestops answering it at once — Escape has no place, and belongs to whatever is open

Without that distinction, a dialog opened from a dialog cannot be closed by two presses of Escape, because the inner one eats the second while it fades. DismissableLayer keeps the stack informed by reading the enclosing Presence and calling set_leaving.

Collection

A composite control needs the set of its own items, in the order they appear, so that ArrowDown means the next one. Nothing else in a retained tree answers that: a parent does not enumerate its children, and the items may be behind a conditional, inside a list, or three components down. So the items announce themselves.

pub struct Collection;   // Copy
impl Collection {
    pub fn new() -> Self;
    pub fn provide() -> Self;
    pub fn current() -> Option<Self>;
    pub fn register(&self, node: NodeRef) -> ItemId;
    pub fn register_reachable_when(
        &self, node: NodeRef, reachable: Signal<bool, LocalStorage>,
    ) -> ItemId;
    pub fn deregister(&self, id: ItemId);
    pub fn items(&self) -> Vec<CollectionItem>;            // tree order, subscribes
    pub fn items_untracked(&self) -> Vec<CollectionItem>;
    pub fn len(&self) -> usize;
    pub fn is_empty(&self) -> bool;
    pub fn index_of(&self, id: ItemId) -> Option<usize>;
    pub fn at(&self, index: usize) -> Option<CollectionItem>;
    pub fn step(&self, from: Option<ItemId>, steps: isize, wrap: bool) -> Option<CollectionItem>;
    pub fn end(&self, last: bool) -> Option<CollectionItem>;
}

pub struct ItemId(u64);
impl ItemId { pub const fn new(value: u64) -> Self; pub const fn get(self) -> u64; }

pub struct CollectionItem;   // Copy
impl CollectionItem {
    pub fn new(id: ItemId, node: NodeRef) -> Self;
    pub fn reachable_when(
        id: ItemId, node: NodeRef, reachable: Signal<bool, LocalStorage>,
    ) -> Self;
    pub fn id(&self) -> ItemId;
    pub fn node(&self) -> NodeRef;
    pub fn is_reachable(&self) -> bool;
    pub fn focus(&self);
}
RuleWhy
The order is tree order, not registration orderA keyed list rebuilds only the rows whose keys moved, so registration order would answer ArrowDown with whichever row was rebuilt last. The sort asks the engine where each node sits.
Registration is opt-in, per componentA decorative separator is not an item, and nothing here has to guess.
Deregistration is automaticregister installs an on_cleanup_local, so an item that unmounts leaves the group without anything being told.
An ItemId is not an indexAn index addresses a different item as soon as anything before it leaves.
step passes over unreachable itemsOne arrow key is always one usable move. step returns None when nothing is reachable, and it terminates: it makes at most one pass over the set.
An item whose node is gone is dropped from the orderA comparison against a node that no longer exists has no answer.

Binding and Controllable

A control with a value has to answer who owns it, and the answer decides what a click does. A checkbox used on its own owns its checked state; the same checkbox inside a form owns nothing and reflects what the form says. Writing the two separately is how a library ends up with a Checkbox and a ControlledCheckbox that drift apart.

pub enum Binding<T: Clone + PartialEq + 'static> {
    Unbound,                                          // Default
    TwoWay(RwSignal<T, LocalStorage>),
    Controlled { read: Signal<T, LocalStorage>, write: UnsyncCallback<T> },
}

impl<T: Clone + PartialEq + 'static> Binding<T> {
    pub fn controlled(
        read: impl Into<Signal<T, LocalStorage>>,
        write: impl Fn(T) + 'static,
    ) -> Self;
    pub fn is_bound(&self) -> bool;
    pub fn is_controlled(&self) -> bool;
    pub fn get(&self) -> Option<T>;
    pub fn get_untracked(&self) -> Option<T>;
    pub fn write(&self, next: T);
}

impl<T> From<RwSignal<T, LocalStorage>> for Binding<T> { /* … */ }
What the caller writesWhich variantWhat a click does
nothingUnboundmoves the component's own value
checked = signal, an RwSignalTwoWaywrites the caller's signal, which moves the control
checked = Binding::controlled(read, write)Controlledcalls write; the control moves only if write moves read

Which variant a caller gets is decided by the type of what they pass, not by a second prop that could disagree with the first.

There is no From<Signal<…>> for Binding, so passing a read-only signal is a compile error at the prop rather than a control that never moves. A compile_fail doctest asserts it in crates/zgui-ui-primitives/src/state/binding.rs. A control that genuinely must not move is a disabled one, and says so.

Controllable is the value as the component reads and writes it, whoever owns it:

pub struct Controllable<T: Clone + PartialEq + 'static>;   // Copy
impl<T: Clone + PartialEq + 'static> Controllable<T> {
    pub fn new(
        binding: Binding<T>, default_value: T, on_change: Option<UnsyncCallback<T>>,
    ) -> Self;
    pub fn uncontrolled(default_value: T, on_change: Option<UnsyncCallback<T>>) -> Self;
    pub fn binding(&self) -> Binding<T>;
    pub fn get(&self) -> T;
    pub fn get_untracked(&self) -> T;
    pub fn signal(&self) -> Signal<T, LocalStorage>;
    pub fn is_bound(&self) -> bool;
    pub fn is_controlled(&self) -> bool;
    pub fn set(&self, next: T);
    pub fn update(&self, change: impl FnOnce(&mut T));
}
impl Controllable<bool> { pub fn toggle(&self); }

default_value is what the value starts at when the binding is Unbound, and is unused otherwise. on_change is told in all three cases, after the binding has been asked; it is an observer, not the thing that makes a bound control work.

set returns without doing anything when the value is already next. A callback that fires when nothing changed is a loop with any caller that echoes it back (writing_the_value_it_already_holds_tells_nobody, in the same file).

A primitive and the component built on it

A styled component in zgui-ui is a primitive composition, plus a variants! table, plus a style! sheet, plus one install_stylesheet call, plus its accessibility bindings. RadioGroup is the shortest case: it is RovingFocus and nothing else.

// crates/zgui-ui/src/radio_group/mod.rs, abridged
#[component]
pub fn RadioGroup(
    #[prop(into, optional)] value: Binding<String>,
    #[prop(into, optional)] default_value: Option<String>,
    #[prop(optional)] on_change: Option<UnsyncCallback<String>>,
    #[prop(into, default = Signal::stored_local(false))] disabled: Signal<bool, LocalStorage>,
    #[prop(default = Orientation::Vertical)] orientation: Orientation,
    #[prop(into, optional)] label: Option<String>,
    #[prop(into, optional)] class: Classes,
    #[prop(attrs)] attrs: Attrs,
    children: Children,
) -> impl IntoView {
    install_stylesheet(SHEET, RadioGroupStyle::CSS);
    let group = NodeRef::new();
    provide_local_context(RadioContext {
        value: Controllable::new(value, default_value.unwrap_or_default(), on_change),
        disabled,
        group,
    });

    // `own` is built here: Role::RadioGroup, the orientation and the label for a reader,
    // plus the two class names. Elided.

    view! {
        RovingFocus(
            orientation = orientation, element_ref = group,
            class = class, {..own}, {..attrs}
        ) {
            {children.into_view_once()}
        }
    }
}

Read the parts off:

LineWhat it is
Controllable::new(value, …)the ownership protocol, so the group works bound and unbound
provide_local_context(RadioContext { … })how an item three components down learns which value is chosen
RovingFocus(orientation = …)the whole keyboard behaviour. There is no key handler in this file
install_stylesheet + RadioGroupStyle::CLASSthe appearance, which is the half the primitive refused
{..own}, {..attrs}the component's own attributes, then the caller's, so the caller wins

The item side is use_roving_item and one control:

// crates/zgui-ui/src/radio_group/item.rs, abridged
let node = NodeRef::new();
let item = use_roving_item(node);
let group = RadioContext::current();

view! {
    control(
        node_ref = node,
        tabindex = move || item.map_or(Focus::Sequential, |item| item.tabindex().get()),
        on:click = move |_| on_click(),
        // Arrowing through a radio group chooses as it goes; that is the pattern's own rule.
        on:focus_in = move |_| choose(),
        {..own}, {..attrs}, class = class
    ) {
        Icon(icon = DISC)
    }
}

RadioGroup writes no data-orientation of its own, because RovingFocus already writes it from the same value. Two writers of one attribute is one of them being wrong at some point.

A worked example: a popover with your own CSS

This uses the primitives and no other part of the library. It is a complete component.

The composition order is fixed and each position has a reason: Portal outermost, so the surface escapes clipping and stacking; Presence next, so the exit runs before the unmount; DismissableLayer next, so it is registered for exactly as long as it is on screen; Popper innermost, because it must measure something that already exists.

use zgui::prelude::*;
use zgui::reactive::{RwSignal, UnsyncCallback};
use zgui::{component, css, view};
use zgui_ui_primitives::prelude::*;

#[component]
fn Popover() -> impl IntoView {
    let open = RwSignal::new_local(false);
    let trigger = NodeRef::new();
    let surface = NodeRef::new();

    view! {
        box {
            control(
                class = "pop-trigger",
                node_ref = trigger,
                tabindex = Focus::Sequential,
                on:click = move |_| open.set(!open.get_untracked())
            ) {
                "Details"
            }
            Portal(layer = OverlayLayer::Popover) {
                Presence(present = Signal::from(open), surface = surface) {
                    DismissableLayer(
                        layer = OverlayLayer::Popover,
                        exclude = trigger,
                        on_dismiss = UnsyncCallback::new(move |_: DismissReason| open.set(false))
                    ) {
                        Popper(
                            anchor = trigger,
                            placement = Placement::BOTTOM,
                            offset = 6.0,
                            class = "pop-at"
                        ) {
                            PopoverPanel(element_ref = surface)
                        }
                    }
                }
            }
        }
    }
}

/// Its own component, because `use_presence` reads a context published above it.
#[component]
fn PopoverPanel(element_ref: NodeRef) -> impl IntoView {
    let presence = use_presence();
    view! {
        box(
            class = "pop-panel",
            node_ref = element_ref,
            attr:data-state = move || presence.map(|presence| presence.state_name().to_owned())
        ) {
            text {"Nine files, 2.1 MB."}
        }
    }
}

The sheet is ordinary CSS. Nothing in it comes from the library.

const SHEET: &str = css!(
    ".pop-trigger {
        padding: 6px 12px;
        border: 1px solid #cdced6;
        border-radius: 6px;
        background-color: #ffffff;
    }
    .pop-panel {
        padding: 12px 14px;
        border: 1px solid #d9d9e0;
        border-radius: 8px;
        background-color: #ffffff;
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
        opacity: 1;
        transform: translateY(0px);
        transition: opacity 140ms ease-out, transform 140ms ease-out;
    }
    .pop-panel[data-state=\"closed\"] {
        opacity: 0;
        transform: translateY(-4px);
    }
    .pop-at[data-side=\"top\"] > .pop-panel[data-state=\"closed\"] {
        transform: translateY(4px);
    }"
);

fn main() -> Result<(), zgui::Error> {
    app().with_stylesheet(SHEET).run(|| view! { Popover() })
}

Four lines carry all the coupling between the Rust and the CSS:

LineWhat it does
attr:data-state = …publishes the presence state, so [data-state="closed"] matches
transition: opacity …, transform …is what Presence waits for. Remove it and the panel goes in the same frame
.pop-at[data-side="top"] > …reads where Popper actually put the surface, and slides from the other direction
class = "pop-at" on the Popperis what gives the selector above something to match. The positioner has no class of its own

Do not give the positioner display: contents. It carries the inline position: fixed and the solved offset, and an element that generates no box cannot be positioned.

What you get for those two components: the panel opens under the trigger, moves above it near the bottom edge of the window, slides along the edge to stay on screen, closes on Escape, closes on a press anywhere outside it, does not close on a press on its own trigger, and fades out before it leaves.

Add FocusScope around the panel and it becomes modal. Add RovingFocus inside it and the arrow keys walk its contents.

The hard parts, and why they are worth not writing twice

Each row is a fault the primitive exists to prevent. Each is a fault that reappears per component when every component writes its own.

Hard partWhat goes wrong without itWhere
Placement in the frame it opensThe surface appears in the wrong corner for one frame. A later correction does not take that frame back.Popper: mounts hidden, measures, writes the offset before paint
Flip that does not oscillateA surface too large for the window flips every frame.Popper: flip only when the opposite side actually fits
Two pixel spacesOn a display of two device pixels per CSS pixel, the surface lands at twice its intended offset.Popper: solve in device pixels, divide by density before writing left and top
The window's box is liveThe first menu is placed correctly and every later one is placed against a window that has changed size.Popper: the window root is observed, not read once
Focus containmentA keyboard user tabs out of a dialog into controls they cannot see.FocusScope, over NodeRef::trap_focus
Focus restorationClosing a dialog drops focus onto the document, and the next Tab starts from the top.FocusTrapOptions::restore
One tab stop per groupA toolbar of twelve buttons is twelve things to tab past.RovingFocus
Disabled but presentAn arrow key strands the keyboard on an item that refuses to be chosen.use_roving_item_when, Collection::step
Item order under a keyed listArrowDown goes to whichever row was rebuilt last.Collection: tree order, asked of the engine
Exit animations that finishThe fade is deleted with the content, or a duration in Rust drifts from a duration in CSS.Presence: unmount on the animation end
Listeners after a remountThe first open animates correctly and every later one is deaf.Listening: re-attach on every rebind
An exit that never endsA scrim and a focus trap sit over a dead window for the rest of the session.Presence: the one-second deadline
Which surface a press belongs toA press past a popover inside a dialog closes the dialog too.LayerStack: topmost only, band before order
Two presses of EscapeThe surface that is fading eats the second press.LayerStack: a leaving layer stops answering Escape and keeps answering presses
The trigger paradoxThe surface never closes from its own trigger, which is the first place everybody presses.DismissableLayer: exclude
Dismiss on capture, not bubbleThe menu closes in the same frame it opens.DismissableLayer: ListenerOptions::CAPTURE
Controlled and uncontrolledThe library grows a Checkbox and a ControlledCheckbox that drift apart.Controllable
Change callbacks that loopon_change fires for a value that did not change, and a caller that echoes it back never settles.Controllable::set returns early on an equal value

What you would write to replace one

Nothing here is unreachable. Presence is the smallest one worth replacing by hand, and the honest first attempt is short:

// A first attempt. It is wrong in four ways.
#[component]
fn Presence(present: Signal<bool, LocalStorage>, children: ChildrenFn) -> impl IntoView {
    let mounted = RwSignal::new_local(present.get_untracked());
    let watching = RenderEffect::new(move |_| {
        if present.get() {
            mounted.set(true);
        } else {
            set_timeout(Duration::from_millis(180), move || mounted.set(false));
        }
    });
    on_cleanup_local(move || drop(watching));

    view! {
        if move || mounted.get() { {children.view()} } else {}
    }
}
What it gets wrongWhat the real one does
180 is a number in Rust that has to agree with a number in CSS.Waits for the animation and transition ends on the surface, so the sheet is the only authority on the duration.
A re-open during the exit meets a timer armed for the exit before it, which then unmounts content that has come back.Takes both pending timers on every open.
set_timeout returns a handle, and dropping a handle cancels the timer. Ignoring the return value here schedules nothing.Holds both handles for the life of the exit.
The content is unmounted while a second animation is still running.Re-asks surface.running_animations() at every end and unmounts only at zero.

Add the two cancel events, the deadline, and the re-attachment of listeners after a remount, and you have written crates/zgui-ui-primitives/src/presence/mod.rs. It is about 260 lines with its comments. That is the trade: write it once yourself, or take the one that already has a test for each of the four rows above (crates/zgui-ui-primitives/tests/presence.rs).

The same arithmetic applies to the others. Popper without solve is the four-rectangle placement problem; with it, solve is a public pure function you can call from your own component and skip only the measuring. DismissableLayer without LayerStack is fine for exactly one open surface and wrong for two.

Next

On this page