Skip to content

Other frameworks and plain DOM

Underglass is split into a runtime and adapters. @underglass/core is the runtime: the GPU engine, the mount boundary, layers, motion and effects. It imports no UI framework. @underglass/react and @underglass/vue are thin adapters over it. Anything that can put elements in a <div> can use the core: plain DOM, a framework adapter, or an Astro island that uses one of them (with a client:only directive, since GPU presentation runs only in the browser).

Everything in Concepts still holds. Layers are original elements, the GPU draws them, and native hit testing, focus and input stay on those elements.

@underglass/vue is the Vue 3 adapter (vue@^3.5). It has the same components as the React package, written as Vue components:

import { createCanvasHosts, mountCanvasApp } from "@underglass/vue";
import App from "./App.vue";
const gpu = mountCanvasApp(createCanvasHosts(document.getElementById("stage")!), App);
await gpu.enable();
<script setup lang="ts">
import { ref } from "vue";
import { Surface, useGPUHost } from "@underglass/vue";
const gpu = useGPUHost();
const name = ref("Ada");
</script>
<template>
<Surface id="profile" origin="center"
:layout="{ x: 40, y: 40, width: 280, height: 160, padding: 16, direction: 'column', gap: 8 }"
:appearance="{ background: 'white', color: '#1d1b26', radius: 16 }">
<label for="name">Name</label>
<input id="name" v-model="name" />
<button @click="gpu.setTransform('profile', { rotation: 4 })">Tilt</button>
</Surface>
</template>
  • mountCanvasApp(options, RootComponent, rootProps?) creates the one persistent Vue app. Pass configure(app) in the options to install plugins (a router, a store) before it mounts.
  • Surface, Box, NativeScroll, NativeOverlay and Presence take the same props as in React. Presence emits complete instead of calling onComplete.
  • useSurfaceRef({ id }) returns a callback for :ref; bind it to your own top-level element. The options may be a ref or getter.
  • useGPUHost() and useNativeOverlayHost() work in setup.
  • Vue re-applies every key of a :style object on each update. Never bind transform, opacity or transform-origin on a layer element: the runtime owns them while the GPU presents, and rewriting them turns GPU off.

The playground page vue.html is a complete example.

mountApplicationModule gives your code the adapter-owned container. Build your DOM there, return a function that tears it down, then enroll each top-level section as a layer:

import { createCanvasHosts, mountApplicationModule, compileStyle } from "@underglass/core";
const hosts = createCanvasHosts(document.getElementById("stage")!);
const panel = document.createElement("section");
Object.assign(panel.style, compileStyle(
{ x: 40, y: 40, width: 280, height: 160, padding: 16 },
{ background: "white", color: "#1d1b26", radius: 16 },
true, // positioned: a layer
));
const button = document.createElement("button");
button.textContent = "Count 0";
let count = 0;
button.onclick = () => { button.textContent = `Count ${++count}`; };
panel.append(button);
const gpu = mountApplicationModule(hosts, {
mount({ container }) {
container.append(panel);
return () => container.replaceChildren();
},
});
gpu.enroll(panel, { id: "panel" });
await gpu.enable();
  • Updates are ordinary DOM writes. The runtime watches the persistent root, so changing text, styles or children keeps GPU on and uploads fresh pixels. There is nothing to call after an update.
  • Layers are direct children of the container. Enroll each one with gpu.enroll(element, { id }) after it is connected, and call the returned release before you remove it.
  • Never move or clone the container yourself. The runtime moves it between the page and the canvas; your elements, listeners and state stay where they are.
  • compileStyle turns the typed Layout and Appearance into a style object with complete string values, so Object.assign(element.style, ...) applies it. Your own CSS works too.
  • Motion, effects and the rest of the controller (setTransform, transition, setEffects, disable, dispose) are the same as in React.

The playground page vanilla.html is a complete example.

A framework adapter has three jobs. The React adapter (packages/react/src/react.tsx) and the Vue adapter (packages/vue/src/index.ts) each show them in about 250 lines:

  1. Mount once. Construct a CanvasRuntime with the hosts and a mount(container, runtime) callback that creates exactly one persistent framework root in container and returns its synchronous unmount. Never remount it for GPU changes: the runtime moves the container with moveBefore, which keeps the framework’s DOM and state.
  2. Register layers. For a typed layer component, render one <section drawable> styled with compileStyle(layout, appearance, true), then call runtime.register({ id, element, layout, transform, origin, decorative }) once the element is in the DOM. Forward later changes with runtime.update and runtime.setOrigin, and call the returned release when it unmounts. For refs to the user’s own elements, use runtime.enroll(element, { id }). Never write transform, opacity or transform-origin on a registered element: the runtime owns them (it sets transform-origin: 0 0 itself) and treats any rewrite as a conflict that turns GPU off. Frameworks that re-apply a whole style object on every update, like Vue, must leave those properties out of it.
  3. Reconcile after each update. Call runtime.requestReconcile() after the framework patches the DOM and before the browser paints (React’s layout effects, Vue’s post-flush hooks). Calls in one update coalesce into one GPU frame.

Depend on @underglass/core as a regular dependency and never bundle it, so every adapter on a page shares one runtime and one engine.

Each adapter must pass the shared browser contract in tests/helpers/framework-contract.mjs: GPU stays on through content, structure and transform updates, the canvas shows fresh pixels, native hits land on the right elements, and disable, enable and dispose keep the original root and state.