Skip to main content

Spatial UI

XR Blocks includes one built-in spatial UI system. It provides flex layout, world-space cards, view-space overlays, text, images, icons, buttons, sliders, themes, validation, and model presentation.

Import UI components from xrblocks. The renderer starts with XR Blocks when a UI root enters the scene. Applications do not enable UI, initialize UIKit, register a renderer, or install a second UI raycaster.

Choose the root by coordinate space

TypeCoordinate spaceSize and positionUse for
UICardWorld spacesize and object transforms use metersMenus, tools, labels, model controls, and movable surfaces in the scene
UIOverlayView spaceLayout is relative to a private full-viewport containerHUDs, status, instructions, and view-fixed controls
UIPanelIts UI parentDescendant UIKit layout unitsNested rows, columns, sections, and visual grouping

UIPanel is not a scene root. Put it inside a UICard or UIOverlay. Use FollowHead on a card when content must remain a real world-space object that follows the viewer. Use an overlay when it must remain fixed to the 2D viewport.

World-space cards

import * as xb from 'xrblocks';

const card = new xb.UICard({
size: {width: 0.6, height: 'auto'},
manipulation: true,
edge: true,
style: {
flexDirection: 'column',
gap: 16,
padding: 24,
backgroundColor: '#202124',
borderRadius: 24,
},
});
card.position.set(0, 1.4, -1);

card.add(
new xb.UIText({
text: 'Welcome',
style: {fontSize: 32, color: '#ffffff'},
}),
new xb.UIButton({
label: 'Continue',
onClick: () => console.log('Continue'),
})
);

xb.add(card);
await xb.init(new xb.Options());

manipulation: true enables the card's standard face-camera move and scale behavior. edge: true adds the outer translation hit band and requires translation to be enabled. Two-source scale uses the card's scale action; the edge does not enable a missing action.

Use height: 'auto' when the card should fit its children, gaps, and padding. Keep the width fixed so text wrapping and percentage-width children have a stable horizontal constraint. Use a numeric height when the surface must stay at a fixed physical size.

View-space overlays

An overlay's world transform does not control its rendered position. Layout it inside the view-space container:

const overlay = new xb.UIOverlay({
style: {
width: 360,
position: 'absolute',
left: '50%',
bottom: 24,
transform: {translateX: '-50%'},
},
children: [new xb.UIText({text: 'Ready', style: {fontSize: 24}})],
});

xb.add(overlay);

Cards and overlays use the theme's surface appearance by default. Set appearance: 'none' for a transparent root used only for layout. UIPanel is a neutral nested element unless its style or theme role gives it an appearance.

Layout units and text

UI values have two separate unit systems:

PropertyMeaning
Fixed UICard.size values and world transformsMeters
UICard.size.height: 'auto'Height calculated from child layout
Descendant numeric width, height, gap, padding, margin, and font sizeUIKit layout units
Percentage stringsPercentage of the applicable parent layout size
autoContent or flex layout chooses the size where supported
Numeric lineHeightMultiplier of fontSize, like CSS unitless line height
lineHeight: 'Npx'Explicit UIKit pixel value
lineHeight: 'N%'Percentage of fontSize

These produce different line spacing:

new xb.UIText({
text: 'Compact\nmultiline text',
style: {fontSize: 24, lineHeight: 1.2}, // 1.2 * fontSize
});

new xb.UIText({
text: 'Fixed\nmultiline text',
style: {fontSize: 24, lineHeight: '32px'},
});

Use whiteSpace: 'pre-line' to preserve explicit line breaks. Combine a fixed height, bottom alignment, and clipped overflow for a newest-first transcript:

const transcript = new xb.UIText({
text: 'You: Hello\n\nGemini: 你好',
style: {
width: '100%',
height: 240,
whiteSpace: 'pre-line',
verticalAlign: 'bottom',
overflow: 'hidden',
},
});

UIText uses a system-font canvas when the default atlas lacks a glyph. The mounted UI element remains stable when text changes between native and Unicode rendering.

Semantic controls and telemetry

Use semantic controls instead of raw pointer listeners:

const status = new xb.UIText({text: 'Ready'});
const slider = new xb.UISlider({
ariaLabel: 'Volume',
min: 0,
max: 1,
step: 0.05,
value: 0.5,
onInput: (value) => (status.text = `Volume ${value.toFixed(2)}`),
onChange: (value) => saveVolume(value),
});

onInput reports live captured changes. onChange reports one completed changed interaction. A canceled slider restores its starting value.

Application status, diagnostics, and telemetry are also UI semantics. Use a mounted UIText, UIPanel, or disabled control instead of drawing text into a canvas sprite:

const diagnostics = new xb.UIText({
text: 'Tracking: waiting',
style: {fontSize: 18, color: '#fbbc04'},
});

function showTrackingState(state) {
diagnostics.text = `Tracking: ${state}`;
diagnostics.style.color = state === 'ready' ? '#34a853' : '#fbbc04';
}

This keeps status readable, layout-aware, themeable, and available to the same interaction and scene-context systems as application UI.

Retained updates

Update public properties directly:

status.text = 'Complete';
button.disabled = true;
button.style.backgroundColor = 'rgba(66, 133, 244, 0.7)';
card.size.width = 0.72;

Content, nested styles, and size mutations update retained backend bindings. They do not require removing and recreating the complete UI tree. Add or remove children only when the application structure actually changes.

A UIButton with custom children must have an ariaLabel. Do not combine custom children with the label or icon convenience fields.

Visual, depth, and pointer participation

These controls are independent:

ConcernControlEffect
Render the object treeobject.visibleHides rendering and excludes the hidden branch from interaction
Visual alphaUI style opacity or an alpha colorChanges appearance; does not disable input
Pointer blockingUI style or object pointerEvents, set to auto or noneIncludes or excludes the object branch from hit resolution
Logical interaction boundaryinteractionEnabled or object.xb.interactionEnabledPrevents ancestors past that boundary from becoming logical targets
Three.js depth testingmaterial.depthTestControls whether existing depth occludes a custom Three.js material
Three.js depth writingmaterial.depthWriteControls whether a custom material writes depth for later draws

A transparent object can still write depth and receive input. Set the control for the behavior you want instead of using transparency as a proxy for depth or interaction.

Themes change palette and structure

Select a built-in preset:

xb.ui.theme = 'grayGlass';
// Also: colorful, glimmer, glimmerOpaque, glimmerAmber, glimmerGreen

A theme contains colors, a shared border radius, and optional style roles for surface, panel, text, button, slider, image, and icon. A style role can change padding, gap, border width, radius, component height, hover state, and other layout or appearance properties. Theme changes are not limited to color.

Use xb.ui.setTheme(update) for a partial update. Use local element styles for intentional exceptions. Theme snapshots are validated and detached from the object passed by the application.

Model and placement composition

Use ModelViewer for a supported interactive glTF, splat, or existing THREE.Object3D. Use Placement scripts when a card must follow another object, follow the viewer, face the camera, orbit, or animate visibility. Attach placement scripts to a UICard root, not to a nested UIPanel.

Built-in manipulation suspends direct placement-script children while the user moves the root and rebases them when manipulation ends.

Extension boundary

Ordinary applications use the semantic classes exported from xrblocks. The UIKit renderer and files under src/ui/internal/ or build/internal/ are private implementation details.

There is currently no public low-level adapter for direct UIKit composition. A specialized addon can own a separate renderer behind its own public entry, but it must expose interaction surfaces through supported XR Blocks APIs and must not import the private built-in UI backend.

Validate layout

After the UI renderer completes a frame, validate one mounted root or all roots:

const report = xb.ui.validate(overlay);
if (!report.ok) console.table(report.issues);

The report identifies invalid layout, overflow, clipped text, and overlay surfaces outside the viewport. ready is false when no completed mounted layout is available.

See templates/01_spatial_ui for buttons, slider input, direct updates, and theme switching.