Type here
Every edit becomes a WebGL texture through SnapDOM.
Type in the card on the left. On every keystroke, SnapDOM re-captures it and a plugin uploads the result straight into a WebGL texture on the right — not a screenshot you take once, a texture that keeps up with you.
A defineExports plugin adds a webgl export that feeds a captured PNG straight into an existing WebGL texture, and a small dirty/working coalescing loop makes sure fast typing never queues up stale captures — only the latest edit ever wins the race to the GPU.
Type in the card on the left and the panel on the right updates within a frame or two — that's a real SnapDOM capture of the live DOM element, decoded into an <img>, and uploaded as a WebGL TEXTURE_2D. The color-theme buttons change a CSS custom property read by the captured element itself, so switching themes is just another DOM mutation flowing through the same capture path. The shader dropdown is different — it never touches SnapDOM at all, it only changes how the fragment shader samples the texture that's already sitting on the GPU.
Edit the text, switch the accent color, or swap shaders while you type. The right pane is a live WebGL canvas with its own independent render loop; SnapDOM only ever refreshes what texture that loop is drawing.
Every edit becomes a WebGL texture through SnapDOM.
input listenerEvery keystroke in the contenteditable element dispatches a native input event. The only listener attached to it calls updateMirror().
updateMirror() sets a dirty flagIf a capture is already running, it just marks the work as dirty and returns — it never queues a second capture. If nothing is running, it enters a while (dirty) loop and captures the editable element with { plugins: [mirrorPlugin], cache: 'full', fast: true }.
defineExports(ctx) adds a webgl exportSnapDOM wires that up automatically as capture.toWebgl(). Calling it hands the export function the capture's own context — including ctx.export.url, the rendered PNG data URL for this specific capture.
The export function creates an Image, points it at ctx.export.url, and once it decodes, hands it to the WebGLMirror instance's update() method, which uploads it as a new TEXTURE_2D and returns the canvas.
The mirror's own requestAnimationFrame loop — started once, completely outside of SnapDOM — redraws that texture every frame, applying whichever shader is currently selected. SnapDOM never drives the render loop; it only refreshes what's on the texture.
dirty re-check collapses bursts of keystrokesBecause dirty is checked again right after each capture finishes, typing a whole sentence quickly produces exactly one trailing capture instead of one per keystroke — the loop always converges on the latest DOM state.
SnapDOM's core exports — toPng, toCanvas, and the rest — hand you a finished image or canvas and stop there. They have no concept of "also push this into an existing WebGL texture and keep it around for a render loop," because that's not a rendering concern, it's a destination the caller invented.
defineExports is the seam SnapDOM exposes for inventing that new output shape: a plugin can return named async functions that receive the capture's context and get wired up as capture.to<Name>() automatically. The beforeSnap/afterRender hooks are used here too, but only to time each round trip for the on-screen readout — they're informational and have nothing to do with the texture update itself.
The plugin factory — it doesn't know or care what a "capture" is beyond ctx.export.url, so it drops into any project that already has a WebGL renderer with an update(url) method:
function webglMirror (renderer) {
let started = 0
let elapsed = 0
return {
name: 'webgl-live-mirror',
beforeSnap () { started = performance.now() },
afterRender () { elapsed = performance.now() - started },
defineExports () {
return {
webgl: async ctx => {
const canvas = await renderer.update(ctx.export.url)
statsEl.textContent = `${elapsed.toFixed(0)} ms · GPU updated`
return canvas
}
}
}
}
}
And the usage — a dirty/working loop guarantees a burst of edits collapses into one trailing capture instead of one per keystroke:
const mirrorPlugin = webglMirror(renderer)
let dirty = false
let working = false
async function updateMirror () {
dirty = true
if (working) return
working = true
while (dirty) {
dirty = false
const capture = await snapdom(editable, { plugins: [mirrorPlugin], cache: 'full', fast: true })
await capture.toWebgl()
}
working = false
}
editable.addEventListener('input', updateMirror)
Every keystroke can trigger a full SnapDOM capture — SVG serialize plus rasterize — so this pattern is best for elements that are cheap to capture: small, no heavy images, no font embedding. For anything bigger, debounce the input listener before calling updateMirror() rather than firing on every character. cache: 'full' and fast: true are doing real work here too: they keep repeated captures of the same element cheap by skipping re-computation that doesn't change between keystrokes — that's not the default cache policy, and it's a deliberate trade-off for this exact "recapture the same node constantly" use case.
Every hook this demo uses — beforeSnap, afterRender, plus defineExports for adding new export formats — is documented in the plugin spec.