SnapDOM Blog

Painting without a canvas element

And a couple of other things that fell out of giving a screenshot library a plugin system with two hooks: afterClone and afterRender.

Zumerlab · July 21, 2026

TL;DR

SnapDOM captures a DOM subtree by cloning it, serializing the clone to an SVG <foreignObject>, and rasterizing that. Its plugin hooks fire on every capture with access to the same closure, so a plugin can hold state across calls that the library itself never persists. Three demos built on that: a paint app with no <canvas> element, a contenteditable box that's secretly rendering through WebGL, and a shooting gallery that uses the exact same trick as the paint app.

The setup

SnapDOM (@zumer/snapdom) is a browser library that clones a DOM element, inlines its computed styles, images and fonts, serializes the clone into an SVG data: URL, and rasterizes that to PNG/Canvas/whatever. Nothing unusual there — it's the same family as html2canvas or dom-to-image, minus a few years of bitrot.

What's less common is that it has a plugin system: a handful of lifecycle hooks (beforeSnap, afterClone, afterRender, beforeExport, afterExport, plus defineExports for registering entirely new output formats) that fire on every capture and receive a mutable context object. We added it mostly for the boring reasons — watermarking, redaction, format conversion. Then a few of us started asking what else two of those hooks were good for, and the answers were more interesting than "add a watermark."

The common thread in everything below: a JavaScript closure survives between function calls, and SnapDOM's core rendering pipeline is stateless by design — every capture re-clones the live element from scratch and forgets it the moment it's done. A plugin sitting in between those two facts can remember things the library itself never will.

Painting without a <canvas> element

The paint demo looks like a normal freehand painting widget. It isn't backed by a <canvas> anywhere. There's one hidden <div> holding an empty SVG layer, and an <img> that gets replaced on every stroke with a fresh snapdom.toPng() result. The "canvas" is the accumulated output of a screenshot library capturing the same tiny element over and over, each time with one more stroke baked in than the last.

The trick is two hooks and one closure variable:

// Trimmed to the two hooks that matter — full version, with stroke
// smoothing and SVG path bookkeeping, is at the link above.
function createPaintPlugin () {
  let savedClone = null
  let pendingStroke = null

  return {
    name: 'paint',
    recordStroke (pathData) { pendingStroke = pathData },
    afterClone (ctx) {
      // Instead of letting SnapDOM clone the pristine source, hand back
      // whatever we saved last time — with the new stroke appended.
      if (savedClone) ctx.clone = savedClone.cloneNode(true)
      if (pendingStroke) appendPath(ctx.clone, pendingStroke)
    },
    afterRender (ctx) {
      // Checkpoint what was actually rendered as the new baseline.
      savedClone = ctx.clone.cloneNode(true)
      pendingStroke = null
    }
  }
}

// on every point added while dragging:
plugin.recordStroke(pathData)
await snapdom.toPng(hiddenSource, { plugins: [plugin] })

Every stroke: append a point to an SVG path, call snapdom.toPng(el, { plugins: [plugin] }) again. afterClone swaps in last time's clone instead of a fresh one and adds the new bit of path; afterRender squirrels away whatever came out the other end. The live source element in the DOM is never touched — the picture lives entirely in the plugin's closure, one clone at a time. Throttle it to a capture every ~120ms while dragging (with a local, SnapDOM-free SVG path for the cursor feedback so it doesn't feel laggy) and it's a usable paint app that happens to be, structurally, a loop of independent screenshots.

It sounds like a roundabout way to draw a picture, and it is — that's rather the point. Nothing about SnapDOM's actual job (clone → serialize → rasterize) changed; what changed is that a plugin gave the loop somewhere to keep its memory.

A text field that's secretly WebGL

The second demo is a box that looks and behaves like a normal editable text field — real caret, real text selection, paste works, Enter inserts a newline. None of the text you see is being painted by the browser's text engine. It's a WebGL <canvas> underneath, and what you're reading is a texture.

Two DOM elements make this work. A fully transparent contenteditable proxy sits visually on top of the canvas — its only job is to give the browser something to put a blinking caret on and to receive real keystrokes, so caret-color is set but the text itself is color: transparent. A second, completely hidden element mirrors whatever you typed and is the one SnapDOM actually captures. A plugin's defineExports adds a webgl export that feeds each capture into a double-buffered WebGL renderer, which cross-fades between the old and new texture over about 110ms so nothing visibly pops:

function seamlessWebgl (renderer) {
  return {
    name: 'seamless-webgl',
    defineExports () {
      return {
        webgl: async ctx => renderer.update(ctx.export.url)
      }
    }
  }
}

// on every keystroke, against the hidden mirror element:
const capture = await snapdom(hiddenSource, { plugins: [plugin], cache: 'full', fast: true })
await capture.to('webgl')

ctx.export.url is the rendered PNG data URL for that specific capture — defineExports hands the plugin the finished render and lets it decide what "exporting" means, in this case "upload as a GPU texture" instead of "hand back an <img>." A shader dropdown on the page (hologram scanlines, CRT, kaleidoscope, a datamosh effect, six more) never touches SnapDOM at all — it's just sampling whichever texture is already sitting on the GPU differently. Same capture pipeline, eight completely different-looking outputs.

The practical caveat, since someone will ask: every keystroke can trigger a full capture, so this only stays smooth on cheap-to-capture elements — small, no heavy fonts or images. A dirty/working flag pair coalesces bursts of fast typing into one trailing capture instead of one per keystroke, which is what actually keeps it usable.

Also: a shooting gallery

One more, mostly to make a point: the target range is a ten-shot shooting gallery where every bullet hole is permanent — the target visibly accumulates damage over a whole game. It uses the identical afterClone/afterRender persistent-clone pattern as the paint app above, just aimed at a different problem. A plugin holds the last rendered clone, appends a jagged little torn-paper hole marker to it on each shot, and hands that back on the next capture. The live target element in the DOM never gets a single bullet hole added to it; the "damage" is entirely inside the plugin.

That it's the same two hooks doing paint accumulation and bullet-hole accumulation is the actual finding here, more than either demo individually: "remember the last capture and build on it" turned out to be a general-purpose primitive, not a one-off hack for a paint app.

Why does a screenshot library need a plugin system at all

Fair question — most of what a plugin does here, you could imagine building without one, by hand-rolling your own capture-and-composite loop around snapdom.toCanvas(). The reason it's a plugin instead is that afterClone and afterRender sit inside SnapDOM's own clone → render pipeline, with access to the same intermediate state (styles, fonts, the fully-resolved clone) that the rest of the pipeline already computed. Reimplementing that loop by hand means reimplementing everything SnapDOM does to make a clone faithful — pseudo-elements, embedded fonts, CSS variables, Shadow DOM — instead of getting it for free and just hooking two points in the middle.

The other three demos in the same set — a card that shatters, glitches, dissolves or goes holographic, a simpler single-texture version of the WebGL mirror above, and a stop-motion flipbook that uses afterRender to just pull data out of a capture instead of accumulating anything — are all on the same page if you want to see the rest of what two or three hooks can do. The plugin spec (PLUGIN_SPEC.md) documents the full hook order and the defineExports API used above.

Build your own plugin

Two hooks and a closure. That's the whole API surface these demos actually use.

Explore pluginsRead PLUGIN_SPEC.md