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
| Type | Coordinate space | Size and position | Use for |
|---|---|---|---|
UICard | World space | size and object transforms use meters | Menus, tools, labels, model controls, and movable surfaces in the scene |
UIOverlay | View space | Layout is relative to a private full-viewport container | HUDs, status, instructions, and view-fixed controls |
UIPanel | Its UI parent | Descendant UIKit layout units | Nested 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:
| Property | Meaning |
|---|---|
Fixed UICard.size values and world transforms | Meters |
UICard.size.height: 'auto' | Height calculated from child layout |
| Descendant numeric width, height, gap, padding, margin, and font size | UIKit layout units |
| Percentage strings | Percentage of the applicable parent layout size |
auto | Content or flex layout chooses the size where supported |
Numeric lineHeight | Multiplier 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:
| Concern | Control | Effect |
|---|---|---|
| Render the object tree | object.visible | Hides rendering and excludes the hidden branch from interaction |
| Visual alpha | UI style opacity or an alpha color | Changes appearance; does not disable input |
| Pointer blocking | UI style or object pointerEvents, set to auto or none | Includes or excludes the object branch from hit resolution |
| Logical interaction boundary | interactionEnabled or object.xb.interactionEnabled | Prevents ancestors past that boundary from becoming logical targets |
| Three.js depth testing | material.depthTest | Controls whether existing depth occludes a custom Three.js material |
| Three.js depth writing | material.depthWrite | Controls 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.