UI
Unia uses HTML and CSS by default for UI panels.
HTML and CSS are rendered with WebGPU and are fully 3D (they are not overlays), so support occlusion culling and work natively and in XR.
Currently the system supports around 95% of the features/functionality for CSS/HTML. Taffyis used for layouts.
Quick Start (Script Context)
In a script, use UI.Panel to create a panel and this.add() to attach it to the scene:
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();
The panel auto-renders when the DOM changes. Each appendChild, className set, or textContent change schedules a microtask render.
UI.Panel Constructor
new UI.Panel(pixelWidth, pixelHeight, dpr?)
pixelWidth/pixelHeight— Resolution of the texture in CSS pixelsdpr— Device pixel ratio multiplier (default1). Use0.6for lower-res panels,2for sharp text
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 engine, and fonts. Must be called before anything else.setContent(html, css)— Parse raw HTML/CSS strings and render (static content path)await update()— Re-render aftersetContent()(used with the static HTML path)await resize(width, height)— Change resolution, recreates texturedispose()— Free GPU resources and event listeners
Properties
document— TheWGPUDocumentinstance (DOM API)hitbox— InvisibleMeshfor engine raycasting. Add viaworld.addRaycastable(ui.hitbox)pixelWidth/pixelHeight— Current resolutiondpr— Device pixel ratio
WGPUDocument
Returned by ui.document. This is the primary API for building UI programmatically.
Methods
createElement(tag)— Create an element (div,button,span,img, etc.)createTextNode(text)— Create a text node (wrapped in a<span>)addCSS(css)— Append CSS rules (accumulates across calls)querySelector(selector)/querySelectorAll(selector)— Query from rootbody— Root element, append your content here
Rendering
The document auto-renders via microtask when any element property changes. You don't need to call render() manually in most cases. If you modify multiple elements in the same synchronous block, only one render fires.
WGPUElement
Created via doc.createElement(tag). Mirrors the browser DOM API.
Properties
el.className = "panel active"; // set class attribute
el.id = "main"; // set id attribute
el.textContent = "Hello"; // set text (clears children)
el.style.backgroundColor = "red"; // inline style (highest specificity)
el.tagName; // 'DIV', 'BUTTON', etc.
el.parentElement; // parent WGPUElement or null
el.children; // array of child WGPUElements
el.firstChild; // first child or null
DOM Manipulation
parent.appendChild(child);
parent.removeChild(child);
parent.insertBefore(newChild, refChild);
parent.replaceChild(newChild, oldChild);
el.remove(); // remove self from parent
el.cloneNode(deep); // clone element (deep=true for subtree)
el.contains(other); // check if other is a descendant
el.innerHTML = ""; // clears all children
Query & Traversal
el.matches(".panel"); // test if element matches selector
el.closest(".wrapper"); // find nearest ancestor matching selector
el.querySelector(".child");
el.querySelectorAll(".child");
el.getElementsByClassName("cls");
el.getElementsByTagName("div");
Attributes
el.setAttribute("src", "path/to/image.png");
el.getAttribute("src");
el.removeAttribute("src");
el.hasAttribute("src");
Dataset
el.dataset.value = "42"; // sets data-value="42"
el.dataset.value; // '42'
delete el.dataset.value; // removes data-value
Class List
el.classList.add("active");
el.classList.remove("active");
el.classList.toggle("active");
el.classList.contains("active"); // boolean
Events
// Property handlers
el.onclick = (e) => { ... }
el.onmousedown = (e) => { ... }
el.onmouseup = (e) => { ... }
el.onmousemove = (e) => { ... }
el.onmouseenter = (e) => { ... }
el.onmouseleave = (e) => { ... }
// addEventListener
el.addEventListener('click', handler)
el.removeEventListener('click', handler)
Events bubble up the tree. Call e.stopPropagation() to stop bubbling. Event objects include clientX, clientY, and target.
Geometry
el.getBoundingClientRect();
// { x, y, width, height, top, left, right, bottom }
// Returns layout coordinates — useful for slider hit-testing
Inline Styles
Set styles directly via the style proxy:
el.style.backgroundColor = "#303036";
el.style.padding = "22px 20px";
el.style.display = "flex";
el.style.flexDirection = "column";
el.style.gap = "12px";
el.style.width = "200px";
el.style.fontSize = "44px";
el.style.color = "#f4f4f6";
el.style.borderRadius = "20px";
Inline styles have highest specificity, overriding CSS rules.
Two Content Modes
1. Document API (recommended)
Build the DOM programmatically with createElement, appendChild, and event handlers. This is what EditDOM.js uses and gives full interactivity.
const doc = ui.document;
doc.addCSS(".box { background: #222; padding: 16px; }");
const box = doc.createElement("div");
box.className = "box";
box.textContent = "Interactive";
box.onclick = () => console.log("clicked");
doc.body.appendChild(box);
2. Static HTML (setContent)
For non-interactive or simple content, pass raw HTML and CSS strings:
ui.setContent(
'<div class="card"><h1>Title</h1><p>Body text</p></div>',
".card { background: #1c1c1f; padding: 20px; border-radius: 12px; }"
);
await ui.update();
Events still work via the event system, but you'd need to query elements after parsing.
Supported CSS
Selectors
| Selector | Example | Supported |
|---|---|---|
| Tag | div |
Yes |
| Class | .panel |
Yes |
| ID | #main |
Yes |
| Universal | * |
Yes |
| Descendant | div .child |
Yes |
| Child | div > .child |
Yes |
| Adjacent sibling | .a + .b |
Yes |
| General sibling | .a ~ .b |
Yes |
| Attribute | [src], [type=text] |
Yes |
| Compound | div.panel#main |
Yes |
| Comma groups | .a, .b |
Yes |
:hover |
.btn:hover |
Yes |
:root |
:root { --color: red; } |
Yes |
:nth-child, :not, etc. |
No | |
::before, ::after |
No |
Layout Properties
| Property | Values |
|---|---|
display |
block, flex, 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 values |
width, height |
px, %, auto |
min-width, min-height |
px, % |
max-width, max-height |
px, %, 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 |
per-side supported |
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 |
inter, jetbrains mono, source serif 4, roboto, open sans, playfair display, fira code, icons, sans-serif, serif, monospace, or any imported asset font name |
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 |
cursor |
parsed but visual cursor not rendered |
Gradients
linear-gradient() and radial-gradient() are supported as background values with up to 8 color stops:
.card {
background: linear-gradient(135deg, #667eea, #764ba2);
}
.radial {
background: radial-gradient(#ff0000, #0000ff);
}
Linear gradients support angle notation (45deg, 0.5turn, 1rad) and directional keywords (to top, to bottom right, etc.). Color stops can include position values.
CSS Custom Properties
var() is supported with fallback values:
:root {
--accent: #4fc3f7;
--radius: 8px;
}
.card {
color: var(--accent);
border-radius: var(--radius);
}
.alt {
color: var(--missing, #fff);
}
Custom properties inherit from parent elements per spec.
Shorthand Expansion
margin, padding, and border shorthands expand to per-side properties. border: 1px solid #fff expands to width, style, and color for all four sides. Per-side shorthands like border-top: 2px solid red also work.
Units
px— absolute pixelsem— relative to element's font-sizerem— relative to 16px root font-size%— percentage of parent dimension
Inheritance
Color, font properties, line-height, letter-spacing, text-align, text-transform, white-space, visibility, and cursor inherit from parent elements.
Supported HTML Elements
Standard block/inline elements render as expected: div, span, p, h1-h6, button, a, ul, ol, li, hr, br, b, strong, em, i.
Heading tags (h1-h6) have default font sizes and bold weight. Semantic tags like em/i render italic, strong/b render bold.
Lists
<ul> and <ol> render with list markers:
<ul>items get bullet points (filled circles)<ol>items get sequential numbers (1., 2., 3., etc.)
Images
const img = doc.createElement("img");
img.setAttribute("src", "path/to/image.png");
img.setAttribute("width", "200");
img.setAttribute("height", "150");
parent.appendChild(img);
Images load asynchronously and trigger a repaint when ready. Changing src via setAttribute only schedules a repaint (not a full relayout), so image swaps are fast.
object-fit and object-position are supported:
img {
object-fit: cover;
object-position: center;
}
Supported object-fit values: fill (default), contain, cover, none, scale-down.
Form Elements
input, textarea, select, and button render with default UA styles and built-in interaction:
- input/textarea — text value rendering, placeholder text (dimmed), text selection highlighting, blinking cursor, focus ring
- select — renders with dropdown arrow, opens a custom overlay with hoverable options
- button — renders as flex container with centered text, default padding and border-radius
Fonts
Built-in Fonts
The following fonts are bundled and available immediately:
| Font Family | Weights | Styles |
|---|---|---|
inter (default) |
400, 700 | normal, italic |
jetbrains mono |
400, 700 | normal, italic (400 only) |
source serif 4 |
400, 700 | normal, italic |
roboto |
400, 700 | normal |
open sans |
400, 700 | normal |
playfair display |
400, 700 | normal |
fira code |
400, 700 | normal |
icons |
400 | normal |
Generic families map to built-in fonts: sans-serif and system-ui resolve to inter, monospace to jetbrains mono, serif to source serif 4.
Custom Asset Fonts
Import a .ttf, .otf, or .woff file as a project asset, then reference it by filename (without extension) in CSS:
const container = new UI.Panel(600, 400, 2.0);
this.add(container);
const init = async () => {
await container.init();
const doc = container.document;
doc.addCSS(`
.pixel { font-family: slkscr; font-size: 20px; color: #4FC3F7; }
.normal { font-family: inter; font-size: 20px; color: #4FC3F7; }
`);
const a = doc.createElement("div");
a.className = "pixel";
a.textContent = "Custom font text";
doc.body.appendChild(a);
const b = doc.createElement("div");
b.className = "normal";
b.textContent = "Default font text";
doc.body.appendChild(b);
};
init();
If the asset is named slkscr.ttf, use font-family: slkscr in CSS. The font loads asynchronously from the asset system — text renders in Inter initially, then swaps to the custom font once loaded. The panel re-renders automatically.
Events & Interaction
The UI panel converts 3D raycasts into 2D pixel coordinates for hit testing. Setup:
// Required for interaction:
world.addRaycastable(ui.hitbox);
The engine automatically calls ui.hitbox.click(), ui.hitbox.hover(), etc. when the user interacts with the panel in 3D space. These are translated to DOM events that bubble through the element tree.
Hover Effects
:hover CSS rules work automatically. When the pointer moves over the panel, the event system tracks which element is hovered, sets internal _hovered flags, and triggers an optimized restyle that only re-matches :hover rules.
Scroll
Elements with overflow: auto or overflow: scroll are scrollable via mouse wheel. The engine forwards wheel events when the panel is hovered.
Patterns from EditDOM.js
Slider with Drag
const makeSlider = (min, max, onChange) => {
const track = doc.createElement("div");
track.className = "slider-track";
const fill = doc.createElement("div");
fill.className = "slider-fill";
track.appendChild(fill);
let dragging = false;
track.onmousedown = (e) => {
dragging = true;
const rect = track.getBoundingClientRect();
const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
);
fill.style.width = ratio * 100 + "%";
onChange(min + ratio * (max - min));
};
track.onmousemove = (e) => {
if (!dragging) return;
const rect = track.getBoundingClientRect();
const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
);
fill.style.width = ratio * 100 + "%";
onChange(min + ratio * (max - min));
};
track.onmouseup = () => {
dragging = false;
};
return track;
};
Toggle Button
const toggle = doc.createElement("button");
toggle.className = "toggle";
const dot = doc.createElement("div");
dot.className = "toggle-dot";
toggle.appendChild(dot);
let checked = false;
toggle.onclick = () => {
checked = !checked;
toggle.className = checked ? "toggle toggle-on" : "toggle";
};
Tab Switching
const tabs = [tabA, tabB, tabC];
const bodies = [bodyA, bodyB, bodyC];
let activeTab = 0;
tabs.forEach((tab, i) => {
tab.onclick = () => {
tabs[activeTab].className = "tab";
bodies[activeTab].className = "body hidden";
tab.className = "tab tab-active";
bodies[i].className = "body";
activeTab = i;
};
});
Performance
- Batched rendering — Microtask coalescing means multiple DOM changes in one synchronous block produce a single render
- Hover fast path —
:hoverchanges only re-match hover rules and re-resolve affected nodes, skipping full relayout - Repaint-only path — Image
srcchanges skip restyle/relayout entirely - Glyph atlas — Text glyphs are cached in a GPU texture atlas; repeated characters are free
- Image caching — Loaded images are cached by URL
Internals
The rendering pipeline:
- CSS Parse —
parseCSS()tokenizes rules into selector + declarations - Selector Match — Rules matched to elements with specificity sorting, results cached
- Style Resolve — Cascade: inherited props, matched rules by specificity, inline styles. Shorthand expansion, unit conversion (
em/remtopx) - Layout — Taffy (Rust WASM) computes flexbox layout. Three passes: initial, text-height correction, final
- Render — Five GPU pipelines collect draw commands grouped by clip rect:
- Rect pipeline — backgrounds, borders, border-radius
- Text pipeline — glyphs from atlas with sub-pixel positioning
- Image pipeline — async-loaded textures with object-fit
- Gradient pipeline — linear and radial gradients (up to 8 stops)
- Shadow pipeline — box-shadow with blur, spread, and offset
The output texture is mapped onto a 3D quad with premultiplied alpha blending.
Current Limitations
Not Supported
- CSS Grid — Taffy supports it internally but it's not wired up
- Animations / Transitions — No
@keyframes,transition, or CSS animations - Media Queries — No
@mediarules - Pseudo-elements — No
::before/::after - Advanced pseudo-classes — No
:nth-child,:not(),:focus,:active,:first-child, etc. - Transforms — No CSS
transformproperty - Filters — No
blur(),brightness(), etc. - Opacity — Not supported as a standalone CSS property (use
rgba()colors instead) - Background Image — Use
linear-gradient()/radial-gradient()or<img>elements instead - Text Decoration — No underline, strikethrough
- Text Overflow — No
text-overflow: ellipsis - Float — Not supported
- Advanced attribute selectors — No
~=,^=,$=,*=operators - Border styles — Only
solidis rendered;dashed,dotted,doubleare parsed but render as solid @font-face— Not supported. Use asset fonts instead (import a.ttf/.otf/.woffand reference by name infont-family)
Partial Support
position: fixed— Treated the same asabsolute- Scrolling — Works but no momentum/inertia, no horizontal scroll, no visible scrollbar track (pill indicator only)