Skip to content
hikari
RenderingEditorAIToolchainDocumentation
GitHub
hikari

© 2026 Flying Rat Studio.
All rights reserved.

Explore the engineDocumentationContributorsLicenseBack to top
Documentation / Guides
Browse docs
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Start exploring
  • No matching sections. Try fewer words or another topic.
NavigateEnter Openesc Close
Overview
Tutorials16
OverviewFirst game projectFirst entityAuthored user_data and the inspectorFirst mesh and materialFirst physics body and triggerFirst character controllerFirst session servicesFirst UIFirst input actionFirst messagesPlay, Edit, and scenesFirst runtime spawnFirst motionFirst skeletal animationAssets in Play (soft refs and hot reload)Dynamic editor recompile
Guides16
OverviewDevelopment guideGameplay APIChoosing component storageBuild and packagingProject filePluginsHikari Plugin APIData-driven content, JSON, and pathsScripting with KawaShader authoringTime of dayUser interfaceUI layoutUI widgetsMigration from Unity / UnrealActor and component lifecycle
Systems28
OverviewArchitectureApplication lifecycleFrontends and driversPlatforms and supportSession services and cross-scene stateScenes and gameplayActor communication (hi.actors)Game-facing refsSave / replication wire versionCoordinate space and camera conventionsRenderingRenderer architecture mapFrame governorGPU particlesVisual ZonesVolumetric mediaInputAudioPhysicsMotion KitTemporal KitAssets and ShinraPrefabsAsset residencyAsset formats (Shinra pipeline)UI and editorEditor asset hot reloadEditor Project Selector
Language reference2
OverviewAkari language referenceKawa language reference
Engine overview
Guides14 min read

UI widgets

On this page
On this pageIconsProgress, tooltips, context menus, drag-drop, wrapping textLists, dropdowns, and segmented controlsPopup overlaysText and numeric fieldsCheckbox and toggleScoped composition, bindings, and edit resultsTree viewsSplit panesTabsTextRectButtonsSlidersFocus and keyboard/gamepad navigationDetecting the active input deviceSpacing and panelsModalsStable widget identity Back to top

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:

zig
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

zig
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:

zig
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:

zig
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.

zig
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).

zig
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.

zig
_ = 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.

zig
_ = 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:

zig
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:

zig
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:

zig
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:

zig
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:

zig
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:

zig
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

zig
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.

zig
// 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.

zig
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:

zig
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

zig
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:

VariantPlateOutline
primary / destructivefillednone
secondary (the default)filled surfacehairline
ghostnone at rest; wash on hover/pressnone

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

zig
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.
zig
// 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:

zig
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:

zig
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:

zig
_ = 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.

PreviousUI layoutNext Migration from Unity / Unreal

Documentation follows the current engine checkout.

Snapshot cc148c75Source docs/guides/ui-widgets.md
On this pageIconsProgress, tooltips, context menus, drag-drop, wrapping textLists, dropdowns, and segmented controlsPopup overlaysText and numeric fieldsCheckbox and toggleScoped composition, bindings, and edit resultsTree viewsSplit panesTabsTextRectButtonsSlidersFocus and keyboard/gamepad navigationDetecting the active input deviceSpacing and panelsModalsStable widget identity Back to top