Controls, focus navigation, modals, and stable IDs for immediate-mode UI.
Hub: User interface · Layout: UI layout
Examples below use host world.ui (UiContext) unless noted. Game modules call hi.ui(). Tabs, numeric fields, sliders, rich text, world anchors, and virtual lists are on UiApi. Host-only: trees, splits, popups, DnD, custom SVG icons.
Icons
Game SDK (hi.ui()): string catalog ids from hi.ui_types.icons, size, and text role:
const ui = hi.ui();
ui.icon(hi.ui_types.icons.save, 20, .primary);
ui.icon(hi.ui_types.icons.search, 20, .secondary);
ui.icon(hi.ui_types.icons.trash, 20, .destructive);Host UiContext: icon(Icon, IconStyle) takes a static SVG Icon value (catalog under ui.icons, or a custom path model). Path commands match absolute M/L/Q/C/Z plus line/circle/rect/polyline — no XML, transforms, fills, or gradients. Custom SVG icons are not on UiApi.
Progress, tooltips, context menus, drag-drop, wrapping text
world.ui.progress(load_fraction, .{ .width = .fill, .height = 6 });
if (world.ui.button("Save", .{ .id = "file.save" })) save();
world.ui.tooltip("Save the active scene", .{});
if (world.ui.beginContextMenu("hier.ctx", row_rect, .{ .width = .{ .points = 180 } })) |menu| {
defer menu.end();
if (world.ui.button("Rename", .{ .id = "hier.rename" })) rename();
if (world.ui.button("Delete", .{ .id = "hier.delete", .variant = .destructive })) delete();
}
// Word-wrap (honors `\n`); width defaults to parent stack width.
world.ui.text("Long body copy that should reflow inside the panel.", .{
.wrap = true,
.role = .secondary,
.size = .body,
});
// Drag payload: type tag + bytes. Payloads up to `drag_payload_inline_cap`
// (160 B — paths and ids) stay inline and cannot fail; larger ones allocate,
// which is the only case where `beginDrag` returns false. Accept on release
// over a rect.
if (dragging) _ = world.ui.beginDrag(asset_type_id, asset_path);
if (world.ui.acceptDrop(drop_rect, asset_type_id)) |path| placeAsset(path);Context menus open on right-click over the given area (same popup layer as beginPopup). Editor hierarchy uses retained contextMenu (Focus / Duplicate / Delete). Tooltips use hover delay; wrapping uses shared ui.text_layout.
Lists, dropdowns, and segmented controls
listItem is a focus-navigable full-width row. Its selected state uses the theme accent; unselected rows use the ghost button treatment. It works inside any stack, and combines naturally with scrollArea for long collections.
selectBox displays the selected label and expands an inline clipped option list. It returns true only when the selected index changes, and options reuse normal button focus and activation behavior:
const qualities = [_][]const u8{ "Low", "Medium", "High" };
if (world.ui.selectBox("graphics.quality", &qualities, &quality_index, .{})) {
applyQuality(quality_index);
}SelectBoxStyle.max_visible_items bounds the expanded list height; longer lists scroll with the mouse wheel.
dropdown is the editor-facing name for the same popup-backed selector and uses DropdownStyle. segmented keeps a small mutually-exclusive option set immediately visible, with an accent-filled active segment:
const transform_modes = [_][]const u8{ "Local", "Parent", "World" };
if (world.ui.segmented("transform.space", &transform_modes, &space_index, .{})) {
updateTransformSpace(space_index);
}
_ = world.ui.dropdown("render.path", &paths, &path_index, .{});Popup overlays
openPopup, beginPopup, closePopup, and isPopupOpen provide floating editor overlays. A popup temporarily detaches from the current stack, ignores ancestor clipping, and its vertex range is moved above ordinary content during finalizeFrame. Only popup controls interact while it is open. Escape, gamepad B, or a pointer press outside the popup dismisses it without clicking through to the underlying control.
if (world.ui.button("Add", .{ .id = "component.add" })) {
world.ui.openPopup("component-menu", world.ui.lastWidgetRect());
}
if (world.ui.beginPopup("component-menu", .{ .width = .{ .points = 220 } })) |popup_value| {
var popup = popup_value;
defer popup.end();
_ = world.ui.button("Mesh Renderer", .{ .id = "component.mesh" });
}selectBox uses this layer automatically, so opening a selector does not shift subsequent inspector rows.
Text and numeric fields
Text fields keep storage explicit: pass a mutable byte buffer and its current length. They support mouse/keyboard focus, typed printable ASCII, insertion at the cursor, Backspace/Delete, Left/Right, Home/End, disabled and read-only states, and platform character capture. A focused editable field reports wantsTextInput() so the host can open a platform text-entry session (physical keyboard on desktop, native virtual keyboard on consoles).
if (world.ui.textField("entity.name", &name_buffer, &name_len, .{})) {
previewEntityName(name_buffer[0..name_len]);
}numericField builds on the same editor and updates a f32 whenever its text parses successfully. min/max clamp accepted values; focused Up/Down or gamepad d-pad input applies step.
_ = world.ui.numericField("transform.position.x", &position.x, .{
.step = 0.1,
.min = -10000,
.max = 10000,
});Fields report per-frame changes. The editor/document layer remains responsible for grouping previews into one undoable command when editing is committed.
Checkbox and toggle
Both controls mutate caller-owned booleans and return true on change. Checkbox styling inherits the active theme by default, while corner_radius = 0 selects a square addRect/addBorder indicator instead of rounded primitives. Its optional checked/unchecked, border, and checkmark colors override individual theme tokens without replacing the theme. Use a checkbox for a property value, and a toggle for a persistent toolbar or mode state.
_ = world.ui.checkbox("entity.visible", "Visible", &visible, .{});
_ = world.ui.toggle("viewport.local", "Local", &local_space, .{});
_ = world.ui.checkbox("entity.locked", "Locked", &locked, .{ .corner_radius = 0 });Scoped composition, bindings, and edit results
withVStack, withHStack, withScrollArea, and withSplitPane accept compile-time callbacks and guarantee every scope ends. They create no closures or heap allocations:
world.ui.withVStack(.{ .spacing = 8 }, editor, struct {
fn build(ui: *hikari.ui.UiContext, state: *EditorState) void {
ui.text("Inspector", .{});
_ = ui.checkboxBound("visible", "Visible", hikari.ui.bind(&state.visible), .{});
}
}.build);view adds a stable automatic ID scope around a reusable view function. Controls inside separate view instances may use short local IDs without collisions:
world.ui.view(entity.scene_id, entity, drawEntityInspector);
fn drawEntityInspector(ui: *hikari.ui.UiContext, entity: *EntityDocument) void {
_ = ui.numericFieldBound("position.x", hikari.ui.bind(&entity.position[0]), .{});
}For manual scopes, use pushId/pushIdValue with defer scope.end(). Composite controls derive their child IDs numerically, without formatting temporary strings.
bind(&value) creates a zero-cost typed pointer binding. bindText(buffer, &len) binds caller-owned text storage. Bound controls return a consistent EditResult:
changed: preview value changed this frame;committed: accept the accumulated edit as one document command;cancelled: discard or revert the document preview.
Cancellation is reported rather than automatically restoring data because the authoritative value may belong to a SceneDocument command transaction rather than a raw field.
propertyRow standardizes inspector label/control alignment while letting the control remain a reusable compile-time callback:
const result = world.ui.propertyRow("Position X", .{}, &entity.position[0], struct {
fn control(ui: *hikari.ui.UiContext, value: *f32) hikari.ui.EditResult {
return ui.numericFieldBound("value", hikari.ui.bind(value), .{ .step = 0.1 });
}
}.control);Tree views
treeNode separates disclosure from row selection and opens an indented scope when expanded. Expansion and selection remain caller-owned, which makes stable scene object IDs the natural widget IDs:
var node = world.ui.treeNode(entity.id, entity.name, &entity.expanded, selected_id == entity.id, .{});
defer node.end();
if (node.clicked) selected_id = entity.id;
if (node.open) {
for (entity.children) |child| drawEntityNode(child);
}The disclosure and row are independently focusable. Activating the disclosure toggles expansion; activating the row reports clicked without changing expansion. Tree views compose with scrollArea for large hierarchies.
Split panes
splitPane creates horizontal or vertical panes with a draggable divider. The caller-owned ratio is clamped by min_first and min_second and can be persisted in editor workspace settings:
var split = world.ui.splitPane("editor.main", &main_split, .{
.direction = .horizontal,
.width = .fill,
.height = .fill,
.min_first = 180,
.min_second = 260,
});
defer split.end();
drawHierarchy();
split.next();
drawViewportAndInspector();Build first-pane content immediately after splitPane, call next() exactly once, then build second-pane content. end() calls next() automatically if necessary, allowing an intentionally empty second pane.
Tabs
tabs draws a focusable tab strip and updates a caller-owned active index. Tabs can size naturally from their labels or share the available width with equal_width:
const inspector_tabs = [_][]const u8{ "Properties", "Components", "History" };
if (world.ui.tabs("inspector.tabs", &inspector_tabs, &active_inspector_tab, .{})) {
persistInspectorTab(active_inspector_tab);
}This first tab primitive covers selection and keyboard/gamepad activation. Close buttons remain higher-level editor behaviors. Editor dock tab strips (components.tabStrip) overflow with a framework horizontal scroller and drag between the left, right, and bottom docks (toolkit/docking.DragSession + RetainedUi.reparent). Tearing a tab clear of every dock opens a Class 1 tool window for that panel; a title-bar dock-back control returns it to the editor. Remount runs the panel's registration again (fill + on_mounted), so plugin/game surfaces keep their content.
Text
world.ui.text("Connected", .{ .role = .accent });
world.ui.text("Connection lost", .{
.role = .destructive,
.scale = 1,
});TextStyle supports a semantic colour role, an optional semantic font override, an explicit color, a glyph scale, and optional word-wrap / max_width. An explicit color overrides the role's theme color. Game SDK (hi.ui()) exposes the same fields.
// SDK: title scale without pushEnv, and a custom colour when needed.
ui.text("THOMAS", .{ .role = .primary, .size = .title, .scale = 2.2 });
ui.text("Danger", .{ .role = .destructive, .color = .{ 1, 0.4, 0.2, 1 } });Games assign cooked TrueType assets to theme font slots at an asset-loading boundary. Text sizes select the matching caption, label, body, or title slot; controls use label. The code slot is available for explicit per-element use. An empty asset resets a slot to built-in Inter.
const poppins = hi.AssetRef.must(.font, "asset://./fonts/poppins/Poppins-Regular");
_ = ui.setFont(.title, poppins);
_ = ui.setFont(.body, poppins);
ui.warmFont(.body, "Příliš žluťoučký kůň"); // loading boundary, not frame drawing
ui.text("Theme title", .{ .size = .title });
ui.text("Forced title face", .{ .size = .body, .font = .title });Rect
Layout-leaf filled rectangle for scrims, fades, and solid colour plates. Prefer this over an empty stack with a surface:
ui.rect(.{
.width = .fill,
.height = .fill,
.color = .{ 0, 0, 0, 1 },
.opacity = 0.55,
});
// or theme role:
ui.rect(.{ .width = .fill, .height = .{ .points = 4 }, .surface = .accent });The default UI font is pre-baked Inter for game HUD. Editor chrome defaults to Geist (Editor Settings → Appearance → UI font can switch back to Inter). Custom static .ttf files are validated and copied to .shinfont by Shinra, then parsed directly by the dependency-free runtime outline loader. Parsed fonts are typed AssetStore residents: every semantic slot owns a retained reference, identical assets share one parsed font, and the Residency panel reports them as FNT rows with their live reference count. Call warmFont at the same loading boundary for non-ASCII text so its outline cache never allocates during frame drawing. Font changes stay in the same flattened UI stream and do not add draw calls. Complex-script shaping is still outside this dependency-free path.
Buttons
if (world.ui.button("Delete", .{
.id = "save-slot.delete",
.variant = .destructive,
.disabled = !can_delete,
.width = .{ .points = 140 },
})) {
deleteSaveSlot();
}A button returns true when the primary mouse button is released over the widget after having been pressed over the same widget, or when it holds keyboard/gamepad focus and Enter, Space, or the gamepad A button was just pressed. Disabled buttons neither hover, click, nor take part in focus navigation.
Available variants are:
| Variant | Plate | Outline |
|---|---|---|
primary / destructive | filled | none |
secondary (the default) | filled surface | hairline |
ghost | none at rest; wash on hover/press | none |
ghost is the chrome-less variant — that is what separates it from a secondary, and why listItem, segmented, tabs and a disclosure row do not box every unselected entry. outline = true boxes one deliberately; fill = false suppresses the plate and its hover/press wash, leaving a pure hit surface for a card that paints its own response (the focus ring still draws — it is a gamepad's only affordance).
ButtonStyle also supports explicit width and height, min/max width, horizontal padding, corner radius, label alignment (align_x — .start is what a menu row wants), text scale, and flex.
Sliders
if (world.ui.slider("settings.ui-scale", &ui_scale, 0.75, 2.0, .{
.step = 0.05,
})) {
world.ui.setUiScale(ui_scale);
}A slider clamps value to the supplied range and returns true when it changes. Drag anywhere on the control with the mouse to set it. Once focused, Left/Right or the gamepad d-pad step the value instead of moving focus away; Up/Down still perform spatial focus navigation. SliderStyle controls sizing, step size, track/thumb dimensions, disabled state, and optional colors. A non-positive step gives continuous mouse movement and uses one percent of the range for keyboard/gamepad stepping.
Game hi.ui().slider(id, value, min, max, style: SliderStyle) returns SliderEdit (changed / committed). Host UiContext.slider returns bool and also takes SliderStyle, not a positional step.
Focus and keyboard/gamepad navigation
Every enabled button and slider takes part in focus navigation automatically — there is nothing to opt into. One widget is focused at a time (ui.focused_id):
- Tab / Shift+Tab move focus forward/backward through declaration order (the order widgets were called this frame), wrapping past the last/first — this matches web/ARIA tab order, which follows document order regardless of visual position.
- Arrow keys and the gamepad d-pad jump to the nearest widget in that direction (nearest by center-to-center distance, weighted toward candidates directly ahead over ones merely closer but off to the side). A focused slider consumes Left/Right for stepping while Up/Down continue to navigate.
- Enter, Space, or gamepad A activate the focused widget.
- Escape or gamepad B closes the open modal and restores whatever was focused before it opened.
- A visible focus ring is drawn only when focus changed via keyboard/gamepad — clicking a widget with the mouse gives it focus but hides the ring. This mirrors the web's
:focus-visible: keyboard users get a clear indicator, mouse users don't get a ring they didn't ask for. - Opening a modal traps focus inside it: Tab/arrow navigation while a modal is open only reaches that modal's own widgets, never the page behind it, matching the ARIA dialog focus-trap pattern.
- Nothing is focused until the user's first Tab/arrow/d-pad press — there is no auto-focus, on the page or inside a freshly opened modal. A mouse-only or controller-idle player never sees a ring they didn't ask for; the first press lands on the first (or, for Shift+Tab, last) focusable widget.
// No extra wiring needed — Tab/arrows/Enter/Space/gamepad already reach this:
if (world.ui.button("Save", .{ .id = "editor.save", .variant = .primary })) {
save();
}Resolving "what's next in tab order" or "what's nearest in this direction" needs every focusable widget's final position for the frame, which — like the justify/flex reflow above — isn't known until the widgets that declare it have actually been built. Navigation is therefore resolved once per frame, at the top of beginFrame, against the previous frame's finished registry of focusable widgets. In practice this means a freshly-appeared row of buttons (or a modal that just opened) needs one rendered frame before its first Tab/arrow press can reach it; a stable layout is exact from then on. There is currently no repeat-while-held for navigation keys (matching the rest of the input system, which is edge-triggered throughout) and no left-stick analog navigation, only the d-pad.
Detecting the active input device
ui.last_input_device (an InputDevice: .mouse, .keyboard, or .gamepad) tracks whichever device produced the most recent activity, updated every frame with no restart or explicit mode switch needed — mouse movement or a click claims .mouse, any key edge claims .keyboard, and a gamepad button edge or a stick pushed past a small deadzone (so idle analog drift doesn't false-trigger it) claims .gamepad. This is the same primitive modern UI frameworks use to swap "Press A" for "Press Enter," or a gamepad-glyph icon for a keyboard-glyph one, the instant the player switches devices:
if (world.ui.last_input_device == .gamepad) {
world.ui.text("Press A to continue", .{});
} else {
world.ui.text("Press Enter to continue", .{});
}There's no built-in icon/glyph set yet — this only exposes the signal to build that on top of.
Spacing and panels
spacer(size) advances the current stack cursor without drawing anything.
panel(rect, color) draws an absolute colored rectangle. It does not participate in stack layout. addRect and addText are also public for low-level custom drawing, but reusable widgets should prefer the normal layout API where possible.
Modals
Open a modal by stable ID, then describe it after the page's normal layout stacks have ended:
var page = world.ui.vStack(.{ .position = .{ 16, 16 }, .width = .{ .points = 200 } });
if (world.ui.button("Quit", .{ .variant = .destructive })) {
world.ui.openModal("confirm-quit");
}
// End the page stack before beginning the modal.
page.end();
if (world.ui.beginModal("confirm-quit", .{})) |modal_value| {
var modal = modal_value;
defer modal.end();
world.ui.text("Quit the game?", .{});
var actions = world.ui.hStack(.{ .height = .{ .points = 36 } });
defer actions.end();
if (world.ui.button("Cancel", .{ .id = "confirm-quit.cancel" })) {
world.ui.closeModal("confirm-quit");
}
if (world.ui.button("Quit", .{
.id = "confirm-quit.accept",
.variant = .destructive,
})) {
world.requestQuit();
}
}An open modal draws a full-viewport backdrop, centers its content panel, and blocks button interaction in the page behind it. Modal content uses the same stack, widget, theme, and stable-ID APIs as normal content.
beginModal must be called with no normal layout stack open. This ensures the modal is a root overlay rather than a child of the invoking panel. It returns null when the requested modal is closed. Only one modal is active at a time; opening another ID replaces the current modal.
ModalStyle controls responsive width and height, minimum and maximum dimensions, padding, spacing, backdrop color, and optional panel background. By default a modal uses 90% of the safe viewport width up to 420 points. It is always clamped to the safe viewport. Call closeModal with the same ID to dismiss it, and use isModalOpen when view logic needs to query its state.
Stable widget identity
Immediate-mode widgets need a stable identity across frames to track pressed state. A button uses its title as its identity unless an explicit .id is supplied.
Always provide IDs when the same title can appear more than once:
_ = world.ui.button("Open", .{ .id = "toolbar.open" });
_ = world.ui.button("Open", .{ .id = "project-dialog.open" });IDs should describe the widget's stable role, not its screen position or a translated label. Two live buttons with the same ID share interaction state and should be treated as an error in application composition.