Theme:

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 pixels
  • dpr — Device pixel ratio multiplier (default 1). Use 0.6 for lower-res panels, 2 for 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 after setContent() (used with the static HTML path)
  • await resize(width, height) — Change resolution, recreates texture
  • dispose() — Free GPU resources and event listeners

Properties

  • document — The WGPUDocument instance (DOM API)
  • hitbox — Invisible Mesh for engine raycasting. Add via world.addRaycastable(ui.hitbox)
  • pixelWidth / pixelHeight — Current resolution
  • dpr — 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 root
  • body — 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 pixels
  • em — relative to element's font-size
  • rem — 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:hover changes only re-match hover rules and re-resolve affected nodes, skipping full relayout
  • Repaint-only path — Image src changes 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:

  1. CSS ParseparseCSS() tokenizes rules into selector + declarations
  2. Selector Match — Rules matched to elements with specificity sorting, results cached
  3. Style Resolve — Cascade: inherited props, matched rules by specificity, inline styles. Shorthand expansion, unit conversion (em/rem to px)
  4. Layout — Taffy (Rust WASM) computes flexbox layout. Three passes: initial, text-height correction, final
  5. 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 @media rules
  • Pseudo-elements — No ::before / ::after
  • Advanced pseudo-classes — No :nth-child, :not(), :focus, :active, :first-child, etc.
  • Transforms — No CSS transform property
  • 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 solid is rendered; dashed, dotted, double are parsed but render as solid
  • @font-face — Not supported. Use asset fonts instead (import a .ttf/.otf/.woff and reference by name in font-family)

Partial Support

  • position: fixed — Treated the same as absolute
  • Scrolling — Works but no momentum/inertia, no horizontal scroll, no visible scrollbar track (pill indicator only)