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
| Behaviour | The question it answers | Kind |
|---|---|---|
Popper | where does this floating surface go, and does it still fit? | component |
FocusScope | may focus leave, and where does it go back to? | component |
RovingFocus | what does the next arrow key do? | component |
Presence | has the exit animation finished yet? | component |
DismissableLayer | does this press belong to me, or to something above me? | component |
Collection | what are my items, in the order a reader meets them? | value |
Controllable | who owns this value — me, or my caller? | value |
Binding | what did the caller tie it to, and what does a click therefore do? | value |
What each one puts in the document:
| Primitive | The element it renders |
|---|---|
Popper | one box, position: fixed, with inline left, top, visibility, plus data-side and data-align |
FocusScope | one box, with data-focus-scope set to the empty string |
RovingFocus | one box, with data-orientation, listening for the arrow keys |
DismissableLayer | one box, with data-layer |
Presence | nothing of its own — it mounts and unmounts its children |
Collection, Binding, Controllable | nothing; 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.
| Attribute | Written by | Values |
|---|---|---|
data-side | Popper, on its positioner | top, right, bottom, left |
data-align | Popper, on its positioner | start, center, end |
data-orientation | RovingFocus | horizontal, vertical, both |
data-layer | DismissableLayer | content, popover, modal, toast |
data-focus-scope | FocusScope | the empty string |
data-state | you, 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
[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| Rule | Detail |
|---|---|
| It renders its own positioner | A 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 opens | The 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 measurements | The 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 nothing | While active reads false, nothing is measured and nothing is solved. Unmounting the surface instead — which Presence does — has the same effect. |
| Two pixel spaces | It 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 IntoViewIt 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.
| Option | MODAL | CONFINE_ONLY | What it does |
|---|---|---|---|
wrap | true | true | tabbing past the last control returns to the first |
auto_focus | true | false | focus moves inside as the trap goes up |
restore | true | false | focus 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 IntoViewThe #[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.
| Key | Effect |
|---|---|
ArrowRight / ArrowLeft | one step, in a Horizontal or Both group |
ArrowDown / ArrowUp | one step, in a Vertical or Both group |
Home / End | the 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:
- 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::namedre-attaches them on every rebind.Listeningis public, atzgui_ui_primitives::presence::Listening. - It waits for both ends of both kinds:
ANIMATION_END,ANIMATION_CANCEL,TRANSITION_ENDandTRANSITION_CANCEL. A cancelled animation produces no end, and content waiting for one would stay mounted for ever. - 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. - An exit that has not finished within one second finishes anyway. The constant is
EXIT_DEADLINEincrates/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:
| Event | A layer that has been told to close, and is still playing its exit |
|---|---|
| a press | still answers it — it is still on the screen, and a press has a place |
Escape | stops 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);
}| Rule | Why |
|---|---|
| The order is tree order, not registration order | A 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 component | A decorative separator is not an item, and nothing here has to guess. |
| Deregistration is automatic | register installs an on_cleanup_local, so an item that unmounts leaves the group without anything being told. |
An ItemId is not an index | An index addresses a different item as soon as anything before it leaves. |
step passes over unreachable items | One 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 order | A 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 writes | Which variant | What a click does |
|---|---|---|
| nothing | Unbound | moves the component's own value |
checked = signal, an RwSignal | TwoWay | writes the caller's signal, which moves the control |
checked = Binding::controlled(read, write) | Controlled | calls 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:
| Line | What 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::CLASS | the 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:
| Line | What 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 Popper | is 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 part | What goes wrong without it | Where |
|---|---|---|
| Placement in the frame it opens | The 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 oscillate | A surface too large for the window flips every frame. | Popper: flip only when the opposite side actually fits |
| Two pixel spaces | On 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 live | The 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 containment | A keyboard user tabs out of a dialog into controls they cannot see. | FocusScope, over NodeRef::trap_focus |
| Focus restoration | Closing a dialog drops focus onto the document, and the next Tab starts from the top. | FocusTrapOptions::restore |
| One tab stop per group | A toolbar of twelve buttons is twelve things to tab past. | RovingFocus |
| Disabled but present | An arrow key strands the keyboard on an item that refuses to be chosen. | use_roving_item_when, Collection::step |
| Item order under a keyed list | ArrowDown goes to whichever row was rebuilt last. | Collection: tree order, asked of the engine |
| Exit animations that finish | The 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 remount | The first open animates correctly and every later one is deaf. | Listening: re-attach on every rebind |
| An exit that never ends | A 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 to | A press past a popover inside a dialog closes the dialog too. | LayerStack: topmost only, band before order |
Two presses of Escape | The surface that is fading eats the second press. | LayerStack: a leaving layer stops answering Escape and keeps answering presses |
| The trigger paradox | The surface never closes from its own trigger, which is the first place everybody presses. | DismissableLayer: exclude |
| Dismiss on capture, not bubble | The menu closes in the same frame it opens. | DismissableLayer: ListenerOptions::CAPTURE |
| Controlled and uncontrolled | The library grows a Checkbox and a ControlledCheckbox that drift apart. | Controllable |
| Change callbacks that loop | on_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 wrong | What 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
The components
The styled inventory, and three components taken apart into the primitives above.
Tokens and theming
The other half: the design tokens the styled layer draws itself with.
Overlays and portals
The framework side of bands, positioning, dismissal and focus containment.
Keyboard and focus
Traversal order, focus traps and the key vocabulary the primitives are written against.
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.
Icons
The zgui-ui-icons crate — twenty-two outlines as constants, one component over the vector element, and how size and colour reach them from CSS.