UI
Unia uses HTML and CSS by default for UI panels, rendered with WebGPU as full 3D objects (not overlays), so they support occlusion culling and work natively in XR.
Layout is powered by Taffy. Around 95% of common CSS/HTML features are supported.
Quick Start
const container = new UI.Panel(512, 512, 2.0);
this.add(container);
const init = async () => {
await container.init();
const doc = container.document;
doc.addCSS(`
.panel { background: #1c1c1f; border-radius: 12px; padding: 16px; }
.title { color: white; font-size: 24px; font-weight: 700; }
.btn { background: #303036; border-radius: 8px; padding: 12px 20px;
color: #f4f4f6; font-size: 16px; }
.btn:hover { background: #3c3c42; }
`);
const panel = doc.createElement("div");
panel.className = "panel";
const title = doc.createElement("div");
title.className = "title";
title.textContent = "Hello World";
panel.appendChild(title);
const btn = doc.createElement("button");
btn.className = "btn";
btn.textContent = "Click Me";
btn.onclick = () => console.log("clicked!");
panel.appendChild(btn);
doc.body.appendChild(panel);
};
init();
Panels auto-render when the DOM changes. Multiple edits in the same synchronous block coalesce into a single render.
UI.Panel
new UI.Panel(pixelWidth, pixelHeight, dpr?, options?)
pixelWidth/pixelHeight— Texture resolution in CSS pixelsdpr— Device pixel ratio (default1). Use2for sharp text,0.6for lower-res panelsoptions.depthTest— Defaults totrue. Pass{ depthTest: false }as the fourth argument to composite this panel's opaque and translucent pixels over the completed scene, including selection outlines (desktop and XR). These panels use a final color-only pass without depth testing or depth writing. Other panels keep their normal depth-tested rendering. This controls rendering only; interaction still uses the closest world raycast hit. Configure beforeinit(); changing it after initialization is unsupported.mipmaps— Set tofalsebeforeinit()for a screen-aligned panel that should sample its full-resolution texture. Defaults totruefor panels viewed at varying distances.
Extends Object3D — position, rotate, and scale it like any scene object. The quad aspect ratio is set automatically from the pixel dimensions.
Resolution vs displayed size
The dimensions passed to UI.Panel describe the canvas the UI is painted on. They
control CSS layout and texture sharpness; they do not directly control how large
the panel appears on screen.
Think of the panel like a printed sign:
pixelWidth,pixelHeight, anddprcontrol how detailed the printing is.panel.scalecontrols how large the sign is in the world and on screen.
For example, this creates a 720 × 480 CSS-pixel layout backed by a sharp 1440 × 960 texture, then makes the whole panel physically small:
const hud = new UI.Panel(720, 480, 2);
hud.scale.set(0.1, 0.1, 0.1);
If you proportionally reduce the panel dimensions and all of its CSS sizes while
leaving panel.scale unchanged, the panel will occupy roughly the same space but
have fewer pixels to render with. The result is usually a blurrier UI, not a smaller
one.
To make an existing panel smaller while keeping it sharp, leave its panel dimensions,
CSS sizes, and dpr alone and reduce its scale instead:
// Half the displayed size, with the original texture resolution intact.
hud.scale.set(0.05, 0.05, 0.05);
This remains true when using UI.Align. Alignment positions the panel and compensates
its authored scale for camera FOV; it does not make the panel's CSS pixels equal to
screen pixels. See Align for the full distinction.
Methods
await init()— Initialize GPU resources, layout, and fonts. Must be called first.setContent(html, css)— Parse raw HTML/CSS strings (static content path)await update()— Re-render aftersetContent()await resize(width, height)— Change resolutiondispose()— Free GPU resources
Properties
document— The document instance (DOM API below)hitbox— Invisible mesh for engine raycasting. Add viaworld.addRaycastable(ui.hitbox)to enable interaction.hitboxSelector— Optional CSS selector for a root card (for example,'.card'). Set beforeinit()to fit the hitbox to that element's layout rectangle on every layout update. Transparent canvas space outside the card passes through to the scene. Keep the card within the canvas; descendants should remain within the card or its scrolling area. The texture size andUI.Alignplacement stay unchanged.floatingDropdowns— Set totrueto let select menus extend beyond ahitboxSelectorcard, within the panel canvas. The click area expands around the open menu and returns to the card when it closes. Defaults tofalse; leave enough transparent canvas space around the card for the menu.pixelWidth/pixelHeight/dpr
Document API
ui.document is a DOM-like API for building panels programmatically.
createElement(tag)— Create an element (div,button,span,img,input, etc.)createTextNode(text)— Create a text nodeaddCSS(css)— Append CSS rules (accumulates across calls)querySelector(sel)/querySelectorAll(sel)body— Root element, append your content here
Use :root, not body, for root-level CSS and inherited styles.
Elements
Elements mirror the browser DOM API.
Properties
el.className = "panel active";
el.id = "main";
el.textContent = "Hello"; // clears children
el.innerHTML = ""; // clears children
el.style.backgroundColor = "red"; // inline style, highest specificity
el.tagName; // 'DIV', 'BUTTON', etc.
el.parentElement;
el.children;
el.firstChild;
el.dataset.value = "42"; // data-value="42"
DOM Manipulation
parent.appendChild(child);
parent.insertBefore(newChild, refChild);
parent.replaceChild(newChild, oldChild);
parent.removeChild(child);
el.remove();
el.cloneNode(deep);
el.contains(other);
Query & Traversal
el.matches(".panel");
el.closest(".wrapper");
el.querySelector(".child");
el.querySelectorAll(".child");
el.getElementsByClassName("cls");
el.getElementsByTagName("div");
Attributes & Classes
el.setAttribute("src", "img.png");
el.getAttribute("src");
el.removeAttribute("src");
el.hasAttribute("src");
el.classList.add("active");
el.classList.remove("active");
el.classList.toggle("active");
el.classList.contains("active");
Events
el.onclick = (e) => { ... }
el.onmousedown = (e) => { ... }
el.onmouseup = (e) => { ... }
el.onmousemove = (e) => { ... }
el.onmouseenter = (e) => { ... }
el.onmouseleave = (e) => { ... }
el.addEventListener('click', handler);
el.removeEventListener('click', handler);
Events bubble up the tree. Call e.stopPropagation() to stop bubbling. Event objects include clientX, clientY, target, and hand.
XR Hand
In XR, e.hand is the controller that triggered the event ('left' or 'right'). On desktop it's null. Use it to pulse the correct controller:
btn.onclick = (e) => {
if (e.hand) controls[e.hand].pulse(1.0, 30);
console.log("clicked!");
};
Geometry
el.getBoundingClientRect();
// { x, y, width, height, top, left, right, bottom }
Inline Styles
Set styles directly via the style proxy:
el.style.backgroundColor = "#303036";
el.style.padding = "22px 20px";
el.style.display = "flex";
el.style.gap = "12px";
el.style.width = "200px";
el.style.fontSize = "44px";
el.style.borderRadius = "20px";
Inline styles override CSS rules.
Static HTML Mode
For non-interactive or simple panels, pass raw HTML and CSS directly:
ui.setContent(
'<div class="card"><h1>Title</h1><p>Body text</p></div>',
".card { background: #1c1c1f; padding: 20px; border-radius: 12px; }"
);
await ui.update();
Supported CSS
Selectors
| Selector | Example | Supported |
|---|---|---|
| Tag / Class / ID / Universal | div, .panel, #main, * |
Yes |
| Descendant / Child / Sibling | div .child, div > .child, .a + .b, .a ~ .b |
Yes |
| Attribute | [src], [type=text] |
Yes |
| Compound | div.panel#main |
Yes |
| Comma groups | .a, .b |
Yes |
:hover, :focus, :root |
.btn:hover, input:focus |
Yes |
:nth-child, :not, ::before, ::after |
No |
Layout Properties
| Property | Values |
|---|---|
display |
block, flex, grid, inline, none |
flex-direction |
row, column |
flex-wrap |
wrap, nowrap |
justify-content |
flex-start, center, flex-end, space-between, space-around, space-evenly |
align-items |
flex-start, center, flex-end, stretch, baseline |
flex |
shorthand, flex-grow, flex-shrink, flex-basis |
gap |
px |
width, height, min-*, max-* |
px, %, auto, none |
margin |
px, auto (centering) |
padding |
px |
position |
static, relative, absolute, fixed |
top, right, bottom, left |
px, auto |
overflow |
visible, hidden, auto, scroll |
scrollbar-gutter |
auto, stable (vertical scrolling) |
box-sizing |
border-box, content-box |
Visual Properties
| Property | Values |
|---|---|
background / background-color |
hex, rgb(), rgba(), named colors, linear-gradient(), radial-gradient(), conic-gradient() |
mask-image / mask |
Two-stop radial alpha masks on gradient backgrounds, such as radial-gradient(transparent 52%, black 55%) |
color |
hex, rgb(), rgba(), named colors |
border |
width style color shorthand |
border-radius |
1–4 circular px values (top-left, top-right, bottom-right, bottom-left) |
border-width / border-color |
per-side supported |
border-style |
solid, none |
box-shadow |
offsetX offsetY blur spread color |
font-size |
px, em, rem |
font-weight |
1–1000, bold, normal |
font-family |
see Fonts |
font-style |
normal, italic |
text-align |
left, center, right |
text-transform |
uppercase, lowercase, capitalize |
white-space |
normal, nowrap, pre |
line-height |
normal, unitless multiplier, px |
letter-spacing |
px |
visibility |
visible, hidden |
Other
- Gradients —
linear-gradient(),radial-gradient(), andconic-gradient()with up to 8 color stops. Linear gradients support angle notation (45deg,0.5turn) and directional keywords (to top right). Conic gradients start at the top and advance clockwise; they supportfrom <angle>and color-stop positions in percentages, degrees, radians, or turns. - Gradient masks —
mask-imageandmaskaccept an inward or outward radial alpha transition on gradient backgrounds. This supports smooth rings without painting an opaque center:mask-image: radial-gradient(transparent 52%, black 55%). - CSS variables —
var(--name, fallback)supported; inherits from parent elements. - Shorthand —
margin,padding,borderexpand to per-side.border-topand friends also work. - Corner radii —
border-radiusaccepts the standard 1–4 value circular shorthand. Elliptical radii using/are not supported. - Units —
px,em,rem,%. - Inheritance — Color, font properties, line-height, letter-spacing, text-align, text-transform, white-space, and visibility inherit.
- XR Haptics —
xr-haptictriggers controller vibration on hover (see XR Haptic Feedback below).
HTML Elements
Standard elements render as expected: div, span, p, h1-h6, button, a, ul, ol, li, hr, br, b, strong, em, i.
- Lists —
<ul>renders bullets,<ol>renders numeric markers. - Images —
<img>loads async fromsrc. Supportsobject-fit(fill,contain,cover,none,scale-down) andobject-position. Changingsrcskips layout for fast swaps. - Form elements —
input,textarea,select,buttonrender with default styles and built-in interaction (focus ring, selection, placeholder, blinking cursor, select dropdown). - Placeholder styling — use
input::placeholderortextarea::placeholderto set hint text color, opacity, and typography independently of the entered value. Unstyled placeholders retain the default UA appearance.
Single-line inputs support click-to-place and drag-to-select through the 3D
pointer event path. Use input.value to set text, input.focus() / blur() to
manage focus, input.select() to focus and select all text, and
input.setSelectionRange(start, end) to select a range. Read selectionStart
and selectionEnd for the current range. Value edits dispatch input events.
Virtual keyboard buttons can set data-preserve-focus="" to keep the active
input's caret and selection when clicked.
Range sliders
<input type="range"> renders a track and draggable thumb in both UI panels
and the standalone DOM renderer. Attributes and JavaScript properties both
support min, max, step, value, and disabled:
const slider = ui.document.createElement('input')
slider.type = 'range'
slider.min = '0'
slider.max = '1'
slider.step = '0.05'
slider.value = '0.5'
slider.style.width = '240px'
slider.style.accentColor = '#b4d785'
slider.oninput = () => console.log(slider.valueAsNumber)
slider.onchange = () => console.log('Committed:', slider.value)
ui.document.body.appendChild(slider)
Range sliders accept browser-style ::-webkit-slider-runnable-track and
::-webkit-slider-thumb CSS rules, with ::-moz-range-track and
::-moz-range-thumb aliases. Style the input with appearance: none, then set
the track and thumb backgrounds, sizes, borders, radii, and shadows in their
pseudo-element rules. For a highlighted progress fill, set the track's
background-color, background-image: linear-gradient(...),
background-size: var(--progress) 100%, and background-repeat: no-repeat,
then update --progress from the input's value. The thumb size is shared by drawing and
pointer interaction.
input[type="range"] { appearance: none; background: transparent; height: 32px; }
input[type="range"]::-webkit-slider-runnable-track {
height: 18px; border-radius: 9px; background: #e8ebef;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none; width: 28px; height: 28px; margin-top: -5px;
border: 2px solid #39bdf0; border-radius: 14px; background: white;
}
Defaults are 0–100 with step 1 and a midpoint value. Values are clamped and
snapped to the step; step="any" allows continuous values. value is a string;
valueAsNumber is numeric. stepUp() and stepDown() change the value by steps.
Programmatic changes do not fire events. defaultValue reflects the HTML value
attribute and does not replace a value already edited by the user or script.
Pointer edits emit input as the value changes and change on release when the
final value differs from the starting value. Canvas mouse/touch and injected
panel desktop/mobile/XR pointers use the same slider behavior. Dragging remains
captured outside the input; leaving an entire 3D panel ends its gesture through
the panel's existing release path.
Focused sliders support arrow keys, Home/End, and Page Up/Down; each keyboard
edit emits input and change. Tab/Shift+Tab navigate the panel's focusable
controls. Disabled sliders cannot be edited or focused.
Use accent-color for the thumb and filled track, width/height for sizing,
direction: rtl to reverse the track, and writing-mode: vertical-lr or
vertical-rl for vertical sliders (top-to-bottom by default). Browser-specific
thumb/track pseudo-elements and native OS slider styling are not implemented.
Fonts
Built-in Fonts
| Font Family | Weights | Styles |
|---|---|---|
inter (default) |
400, 700 | normal, italic |
jetbrains mono |
400, 700 | normal, italic (400 only) |
icons |
400 | normal |
Generic families map to built-ins: sans-serif/system-ui → inter, monospace → jetbrains mono.
Runtime Icon Fonts
UI.registerIconFont(family, paths) creates and registers an icon font from
filled SVG path data in a 24 × 24 coordinate space. It returns a map of icon
names to characters for use in text elements. Generate it once before creating
the panel that uses it; the UI text renderer draws the glyphs without image
loading or canvas rasterization.
const icons = UI.registerIconFont('gameicons', {
marker: 'M 12 2 L 22 22 L 2 22 Z',
})
const icon = panel.document.createElement('span')
icon.textContent = icons.marker
// CSS: .icon { font-family: gameicons; }
SVG stroke attributes do not become font outlines; supply filled paths instead.
Custom Asset Fonts
Import a .ttf, .otf, or .woff as a project asset and reference it by filename (without extension) in CSS:
.pixel {
font-family: slkscr;
font-size: 20px;
}
Font names and aliases are matched case-insensitively, with or without the extension. Runtime loading uses the browser cache first, then the cloud asset; local project files are only mirrors.
Fonts load asynchronously — text renders in Inter initially, then swaps and rerenders once loaded.
Custom variable fonts with a wght axis use the requested numeric
font-weight continuously and clamp it to the range declared by the font.
Custom static fonts render their authored weight. Built-in font families use
their supplied 400 or 700 face (1–599 selects 400 and 600–1000 selects
700).
Events & Interaction
To enable interaction, register the panel's hitbox for raycasting:
world.addRaycastable(ui.hitbox);
The engine converts 3D raycasts into 2D pixel coordinates and dispatches DOM events through the element tree. :hover rules, text input, and wheel scrolling on overflow: auto/scroll elements all work automatically.
Scrollbars are engine-rendered overlays on every platform and do not reduce content width by default. scrollbar-gutter: stable explicitly reserves 10 CSS pixels for the vertical indicator and its padding on overflow: auto, scroll, or hidden, even before content overflows. This keeps panel layouts independent of the host OS's native scrollbar settings.
XR input is fully supported — controller rays and hand tracking drive the same event pipeline, so hover, click, and scrolling behave the same in VR/AR as they do with a mouse.
XR Haptic Feedback
Use the custom xr-haptic CSS property to trigger controller vibration when hovering over elements. This is typically used in :hover rules so users feel a subtle pulse as they move between interactive elements.
xr-haptic: <intensity> <duration>;
intensity— Vibration strength from0to1.0duration— Length in milliseconds
.btn:hover {
background: #3c3c42;
xr-haptic: 0.2 20;
}
Set xr-haptic: none on a child element to opt out of a parent's haptic. The property bubbles up the tree, so setting it on a container applies to all children:
.toolbar:hover { xr-haptic: 0.15 15; }
.toolbar .no-haptic:hover { xr-haptic: none; }
It can also be set via inline styles:
el.style.xrHaptic = "0.3 25";
For click haptics, use e.hand in the event handler to pulse the correct controller:
btn.onclick = (e) => {
if (e.hand) controls[e.hand].pulse(1.0, 30);
};
On desktop (no XR), xr-haptic is ignored and e.hand is null.
Focus styles
:focus matches the element currently focused by the panel's focus manager.
It updates on pointer or keyboard focus, programmatic element.focus(), and
element.blur(). Moving focus restores the previous element's normal styles.
Focus rules can change layout as well as colors and borders.
input:focus { border: 1px solid #0d70e8; background: #edf4ff; }
:focus-visible and :focus-within are not supported.
Limitations
Not Supported
- Animations / transitions (
@keyframes,transition) - Media queries (
@media) - Pseudo-elements (
::before,::after) - Advanced pseudo-classes (
:nth-child,:not,:focus-visible,:focus-within,:active, etc.) - CSS
transform - Filters (
blur(),brightness(), etc.) - Standalone
opacity— usergba()colors instead background-image— use gradients or<img>elements- Text decoration (underline, strikethrough)
text-overflow: ellipsisfloat- Advanced attribute selectors (
~=,^=,$=,*=) - Non-solid border styles (
dashed/dotted/doublerender as solid) @font-face— use asset fonts instead
Partial Support
mask-image/mask— radial alpha masks currently apply to gradient backgrounds; image, solid-color, text, and descendant masking are not yet supportedposition: fixed— treated asabsolute- Scrolling — no momentum/inertia, no horizontal scroll, no visible scrollbar track
Checkboxes and custom switches
<input type="checkbox"> provides native checked state, label activation, focus,
Space-key activation on release, and bubbling input and change events.
The checkbox updates before its click handlers run. Cancelling that click with
preventDefault() restores its previous checked and indeterminate state.
Disabled controls cannot be activated by the user or .click().
Use .checked for current state, .defaultChecked (or the checked attribute)
for the initial state, and .indeterminate for a mixed state. Assigning these
properties does not emit user events. A checkbox's default .value is "on".
Both nested labels and <label for="id"> activate the associated checkbox.
CSS supports :checked, :indeterminate, :disabled, and :enabled.
Set appearance: none to replace the default checkbox drawing. Use real sibling
elements for custom tracks and thumbs; checkbox support does not add general
::before/::after pseudo-elements.
<label class="switch">
<input type="checkbox" id="roof-wall" />
<span class="track"><span class="thumb"></span></span>
<span>Roof wall</span>
</label>
.switch { display: flex; align-items: center; gap: 10px; }
.switch input { appearance: none; width: 0px; height: 0px; padding: 0px; border: 0px; }
.track { width: 52px; height: 28px; border-radius: 14px; background: #e1e5e9; padding: 2px; display: flex; }
.thumb { width: 24px; height: 24px; border-radius: 12px; background: #ffffff; }
.switch input:checked + .track { background: #27c6e4; justify-content: flex-end; }
Listen to the checkbox's onchange and read event.target.checked to update
game state. Checkboxes do not open text editing or a virtual text keyboard.
Number inputs
<input type="number"> supports .value, .valueAsNumber, min, max,
step (default 1, or "any"), required, and readOnly.
Programmatic invalid values sanitize to an empty string; an empty numeric value
has valueAsNumber === NaN. Assigning a value does not clamp it to min/max or
round it to step. Constraints are reported through .validity, including
valueMissing, badInput, rangeUnderflow, rangeOverflow, stepMismatch,
and customError, plus .validationMessage and .willValidate.
Use .stepUp() / .stepDown() to adjust to valid steps within the bounds.
These methods throw InvalidStateError with step="any" and do not emit user
events. Arrow Up/Down perform user stepping and emit input and change.
Number editing accepts negative values, decimals, and scientific notation,
including incomplete values during typing; incomplete values expose an empty
.value and set validity.badInput. Disabled and readonly inputs cannot be
edited. Number fields retain normal keyboard capture while focused.
.checkValidity() and .reportValidity() return validity and dispatch a
non-bubbling invalid event when appropriate. .setCustomValidity(message)
sets an author error. :valid and :invalid can style number fields.
reportValidity() does not display a browser validation popup; render the
validation message in the panel when desired. Number fields currently use the
text-field visual style, with keyboard/programmatic stepping rather than built-in
spinner buttons.
const input = panel.document.createElement('input');
input.type = 'number';
input.min = '0.05';
input.max = '3';
input.step = '0.05';
input.value = '0.25';
input.onchange = () => {
if (input.checkValidity()) setThickness(input.valueAsNumber);
};
Focus lifecycle
Removing a focused input (including replacing its ancestor's content) clears focus without committing a stale edit during the rebuild. Hidden or disabled focused controls also release keyboard capture. Hiding a whole panel clears its focus. Clicking into the desktop 3D world, or another panel, blurs the old field and commits its pending change before dispatching the new action. System keyboard panels preserve their associated field's focus while keys are pressed.