Type here
This text is real DOM, but you're seeing it as a WebGL texture kept in sync by SnapDOM.
Click into the box below and type. Every glyph you "see" is a WebGL texture rendered from the last SnapDOM capture: the real, editable DOM is invisible on purpose, and updates cross-fade in so nothing ever pops.
Two DOM elements do the work: a transparent contenteditable proxy that exists only to give the browser a real caret and real keystroke handling, and a hidden source element that mirrors the typed text and is what SnapDOM actually captures. A defineExports plugin feeds each capture into a WebGL renderer that double-buffers two textures and cross-fades between them, so updates never visibly pop: you always see the previous frame smoothly blend into the new one.
Clicking into the box and typing feels like a normal text field: you get a blinking caret, text selection, the works. None of that text is being rendered by the browser's text engine, though. Every glyph on screen is a pixel in a WebGL texture, produced by capturing a completely different, hidden DOM element with SnapDOM and mixing the result into the canvas underneath your cursor. Pick a shader from the dropdown to see the same texture pipeline reused for very different visual effects: the capture and the compositing don't change, only what the fragment shader does with the finished frame.
Type, paste, or hit Enter. The caret and selection are real; the letters are not.
This text is real DOM, but you're seeing it as a WebGL texture kept in sync by SnapDOM.
The .input-layer sits visually on top of the WebGL canvas with fully transparent text and background. It exists only to give the browser something to place a real caret on and to receive real keyboard and paste events; it never renders anything visible itself.
On every input event, plus paste, plus Enter (a beforeinput override inserts a literal newline instead of letting contenteditable create a new <div>), the proxy's text is copied into the hidden #dom-source element and refresh() runs.
refresh() coalesces burstsSame dirty/working loop as the live-mirror demo: if a capture is already in flight when new input arrives, it's flagged dirty and picked up by the loop that's already running, instead of stacking up parallel captures. It captures #dom-source with the seamlessWebgl plugin attached.
The plugin's defineExports returns a webgl export. Calling it hands the capture's URL to renderer.update(ctx.export.url) instead of returning an image to the page.
SeamlessRenderer.update() loads the new capture into whichever of its two textures, current or next isn't currently on screen, then starts a roughly 110ms blend toward it.
The render loop's fragment shader mixes beforeImage and afterImage by a blend uniform that ramps 0→1 over that window. Whatever shader is selected in the dropdown runs on top of that already-blended result.
Same argument as the plain live-mirror demo: SnapDOM's core exports return a finished image and stop; they have no concept of "push this into one of two alternating GPU textures for a crossfade." defineExports is the seam SnapDOM exposes for exactly that: a plugin can register a named export, here, webgl, one that does whatever it wants with the render instead of handing back a PNG.
What's different here versus the plain live-mirror demo is architectural, not plugin-API related. Splitting the caret-owning element from the capture-source element is what makes the transition look seamless: the visible surface (the canvas) is never waiting on a capture to know what to show. It's always mid-blend between two textures it already has, while the hidden source quietly gets captured again in the background.
The plugin factory. it just forwards a capture's URL to whatever renderer you hand it:
function seamlessWebgl (renderer) {
let started = 0
let elapsed = 0
return {
name: 'seamless-webgl',
beforeSnap () { started = performance.now() },
afterRender () { elapsed = performance.now() - started },
defineExports () {
return {
webgl: async ctx => {
const canvas = await renderer.update(ctx.export.url)
perfLabel.textContent = `${elapsed.toFixed(0)} ms · texture swapped`
return canvas
}
}
}
}
}
And the usage. note this calls the plugin's export through the generic capture.to('webgl'), not a generated capture.toWebgl() helper. Both work; .to(name) is what lets a plugin-defined export be invoked without SnapDOM knowing its name ahead of time:
const plugin = seamlessWebgl(renderer)
let dirty = false
let working = false
async function refresh () {
dirty = true
if (working) return
working = true
while (dirty) {
dirty = false
const capture = await snapdom(source, { plugins: [plugin], cache: 'full', fast: true })
await capture.to('webgl')
}
working = false
}
The transparent-proxy trick only works cleanly for plain text editing. Anything that needs real DOM selection or formatting UI (rich text, IME composition edge cases) needs more care than this demo attempts; the proxy here deliberately strips formatting down to a literal newline on Enter and plain text on paste. Double-buffering also costs one extra WebGL texture's worth of GPU memory compared to the single-texture live-mirror demo, a deliberate trade for the flicker-free feel, and one that only makes sense when the crossfade is actually the point.
Every hook this demo uses (beforeSnap, afterRender, and defineExports for adding new export formats) is documented in the plugin spec.