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?)
pixelWidth/pixelHeight— Texture resolution in CSS pixelsdpr— Device pixel ratio (default1). Use2for sharp text,0.6for lower-res panels
Extends Object3D — position, rotate, and scale it like any scene object. The quad aspect ratio is set automatically from the pixel dimensions.
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.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
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, :root |
.btn:hover |
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 |
box-sizing |
border-box, content-box |
Visual Properties
| Property | Values |
|---|---|
background / background-color |
hex, rgb(), rgba(), named colors, linear-gradient(), radial-gradient() |
color |
hex, rgb(), rgba(), named colors |
border |
width style color shorthand |
border-radius |
px |
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 |
400, 700, 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()andradial-gradient()with up to 8 color stops. Linear gradients support angle notation (45deg,0.5turn) and directional keywords (to top right). - CSS variables —
var(--name, fallback)supported; inherits from parent elements. - Shorthand —
margin,padding,borderexpand to per-side.border-topand friends also work. - 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).
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.
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;
}
Fonts load asynchronously — text renders in Inter initially, then swaps once loaded.
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.
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.
Limitations
Not Supported
- Animations / transitions (
@keyframes,transition) - Media queries (
@media) - Pseudo-elements (
::before,::after) - Advanced pseudo-classes (
:nth-child,:not,:focus,: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
position: fixed— treated asabsolute- Scrolling — no momentum/inertia, no horizontal scroll, no visible scrollbar track