Theme:

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 pixels
  • dpr — Device pixel ratio (default 1). Use 2 for sharp text, 0.6 for lower-res panels
  • options.depthTest — Defaults to true. 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 before init(); changing it after initialization is unsupported.
  • mipmaps — Set to false before init() for a screen-aligned panel that should sample its full-resolution texture. Defaults to true for 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, and dpr control how detailed the printing is.
  • panel.scale controls 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 after setContent()
  • await resize(width, height) — Change resolution
  • dispose() — Free GPU resources

Properties

  • document — The document instance (DOM API below)
  • hitbox — Invisible mesh for engine raycasting. Add via world.addRaycastable(ui.hitbox) to enable interaction.
  • hitboxSelector — Optional CSS selector for a root card (for example, '.card'). Set before init() 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 and UI.Align placement stay unchanged.
  • floatingDropdowns — Set to true to let select menus extend beyond a hitboxSelector card, within the panel canvas. The click area expands around the open menu and returns to the card when it closes. Defaults to false; 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 node
  • addCSS(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(), and conic-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 support from <angle> and color-stop positions in percentages, degrees, radians, or turns.
  • Gradient masks — mask-image and mask accept 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, border expand to per-side. border-top and friends also work.
  • Corner radii — border-radius accepts 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-haptic triggers 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 from src. Supports object-fit (fill, contain, cover, none, scale-down) and object-position. Changing src skips layout for fast swaps.
  • Form elements — input, textarea, select, button render with default styles and built-in interaction (focus ring, selection, placeholder, blinking cursor, select dropdown).
  • Placeholder styling — use input::placeholder or textarea::placeholder to 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 from 0 to 1.0
  • duration — 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 — use rgba() colors instead
  • background-image — use gradients or <img> elements
  • Text decoration (underline, strikethrough)
  • text-overflow: ellipsis
  • float
  • Advanced attribute selectors (~=, ^=, $=, *=)
  • Non-solid border styles (dashed/dotted/double render 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 supported
  • position: fixed — treated as absolute
  • 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.