Labs Experiment

The text you see isn't text

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.

TL;DR

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.

What you're looking at

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.

Try it

Type, paste, or hit Enter. The caret and selection are real; the letters are not.

Live DOM signal

Type here

This text is real DOM, but you're seeing it as a WebGL texture kept in sync by SnapDOM.

LIVE GPU
Hidden DOM → double-buffered texturestarting…

How it works

  1. A transparent proxy owns the caret

    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.

  2. Every edit is mirrored to a hidden element

    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.

  3. refresh() coalesces bursts

    Same 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.

  4. The plugin exports into the renderer

    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.

  5. The idle texture is loaded, not the visible one

    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.

  6. The shader mixes, then the effect applies

    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.

Why this needed a plugin

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 — 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.

Copy-paste example

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
}
input sync #dom-source refresh() capture.to('webgl') renderer.update() blend 0→1 shader

Limits and things to watch

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.

Build your own plugin

Every hook this demo uses — beforeSnap, afterRender, and defineExports for adding new export formats — is documented in the plugin spec.

Explore pluginsRead PLUGIN_SPEC.md