Labs Experiment

The PNG is the canvas

There is no <canvas> element anywhere on this page. A SnapDOM plugin holds onto the clone from the last capture and keeps drawing on top of it — every stroke you make becomes the next exported PNG.

TL;DR

SnapDOM normally re-clones the live element on every capture and forgets the last one. This plugin's afterRender hook saves the clone it just produced; afterClone swaps that saved clone back in on the next call and appends the newest stroke to it. Capture after capture, the output accumulates — the PNG becomes a paint surface instead of a snapshot.

What you're looking at

Pick a color and brush size, then drag across the board. What looks like a normal <canvas> painting widget is actually a single hidden <div> holding an empty SVG layer, plus an <img> that gets replaced every time SnapDOM re-renders it. There is no persistent bitmap buffer anywhere in the page's own code — the persistence lives entirely inside the plugin.

Try it

Draw on the board below. The line under your cursor is a local SVG path with zero SnapDOM involvement — it never waits on a capture. Underneath, roughly every 120ms, that same stroke gets folded into the plugin's saved clone and re-exported as a real PNG.

How it works

  1. A hidden source element, never the visible board

    A 420×420 <div> off-screen holds one empty <svg class="strokes">. This — not the board you see — is what SnapDOM actually captures.

  2. The visible line is free

    While you drag, a local SVG <path> on the visible board follows the pointer using getCoalescedEvents() for smooth sampling. This never touches SnapDOM, so the line itself has zero capture latency.

  3. The stroke is handed to the plugin periodically

    Roughly every 120ms, the in-progress stroke's path data is passed to plugin.recordStroke() and snapdom.toPng() runs again on the hidden source.

  4. afterClone restores state, then applies the stroke

    Instead of cloning the pristine (empty) source, the plugin swaps in the clone it saved from the previous render. It then finds the in-progress stroke's <path> by a data-stroke-id and updates it — or appends a new one if this is a new stroke. One continuous drag stays one <path> element, never hundreds.

  5. afterRender checkpoints the result

    After SnapDOM finishes rendering, the plugin clones what it just produced and holds onto it as the new baseline for the next capture.

  6. The output <img> replaces the board's picture

    The resulting PNG is inserted directly into the board. The download button just points at that same <img>.src — no second capture needed.

Why this needed a plugin

SnapDOM's default behavior is stateless by design: every call re-clones the live element and renders exactly what it looks like right now. That's correct for a screenshot tool and wrong for a paint app, which needs the opposite — remember what was drawn a moment ago and add to it.

There's no snapdom() option for "accumulate," because accumulation isn't a rendering concern, it's application state — and the plugin system is exactly the seam SnapDOM exposes for that. The afterClone and afterRender hooks fire on every capture with access to the same closure, so a plugin can hold a private savedClone variable across calls that plain application code has no way to inject into SnapDOM's clone → render pipeline. Two hooks and one closure variable turn a stateless capture function into a stateful one.

Copy-paste example

The plugin factory — swap in your own persistence logic and this pattern works for any "keep building on the last capture" use case, not just painting:

function createPaintPlugin (sourceEl) {
  let savedClone = null
  let skipNext = false
  let pendingStroke = null

  function makePath (stroke) {
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
    path.setAttribute('data-stroke-id', stroke.id)
    path.setAttribute('d', stroke.d)
    path.setAttribute('fill', 'none')
    path.setAttribute('stroke', stroke.color)
    path.setAttribute('stroke-width', stroke.size)
    path.setAttribute('stroke-linecap', 'round')
    path.setAttribute('stroke-linejoin', 'round')
    return path
  }

  return {
    name: 'paint',
    recordStroke (stroke) { pendingStroke = stroke },
    afterClone (ctx) {
      if (skipNext) { skipNext = false; pendingStroke = null; return }
      if (savedClone) ctx.clone = savedClone.cloneNode(true)
      if (!pendingStroke) return
      const layer = ctx.clone.querySelector('.strokes')
      const existing = layer.querySelector(`[data-stroke-id="${pendingStroke.id}"]`)
      if (existing) existing.setAttribute('d', pendingStroke.d)
      else layer.append(makePath(pendingStroke))
    },
    afterRender (ctx) {
      savedClone = ctx.clone.cloneNode(true)
      pendingStroke = null
    },
    clear () { skipNext = true }
  }
}

And the usage — capture again on every stroke update, plugin included:

const plugin = createPaintPlugin(canvasSource)

async function render (stroke) {
  if (stroke) plugin.recordStroke(stroke)
  const img = await snapdom.toPng(canvasSource, { plugins: [plugin] })
  board.replaceChildren(img)
}

// on clear: plugin.clear() then render() again — skipNext lets SnapDOM
// clone the pristine live element once, instead of hand-building an
// empty replacement clone that might miss pipeline setup.
stroke recordStroke() snapdom.toPng() afterClone (restore + append) afterRender (checkpoint) <img>

Limits and things to watch

Every stroke update is a full SnapDOM capture, so the render loop is throttled (120ms here) rather than firing on every pointermove — captures aren't free, and a paint app that tries to snapshot on every mouse event will fall behind. The visible "live" path is what keeps the drawing feeling instant while the real PNG catches up underneath. The skipNext flag matters more than it looks: without it, clear() would need to hand-build an empty clone instead of letting SnapDOM's own cloning pipeline produce one, and any pipeline behavior added later (pseudo-elements, SVG defs inlining, etc.) would silently stop applying to the very first render after a clear.

Build your own plugin

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

Explore pluginsRead PLUGIN_SPEC.md