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
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.The visible line is free
While you drag, a local SVG
<path>on the visible board follows the pointer usinggetCoalescedEvents()for smooth sampling. This never touches SnapDOM, so the line itself has zero capture latency.The stroke is handed to the plugin periodically
Roughly every 120ms, the in-progress stroke's path data is passed to
plugin.recordStroke()andsnapdom.toPng()runs again on the hidden source.afterClonerestores state, then applies the strokeInstead 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 adata-stroke-idand updates it — or appends a new one if this is a new stroke. One continuous drag stays one<path>element, never hundreds.afterRendercheckpoints the resultAfter SnapDOM finishes rendering, the plugin clones what it just produced and holds onto it as the new baseline for the next capture.
The output
<img>replaces the board's pictureThe 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.
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.