A capture, shattered
Five WebGL shaders, one starting point: a SnapDOM capture of a live, editable card, turned into a WebGL texture. Nothing here exists without rasterizing the DOM first.
A custom defineExports plugin (shatterExport) adds a custom export type — result.toShatter() — that isn't a thin wrapper around toPng/toCanvas. It reuses the core canvas exporter internally via the "silent" ctx.exports.canvas() facade (no re-triggered beforeExport/afterExport hooks), then slices that canvas into a grid of small canvases for the physics simulation to grab as WebGL textures. The other four effects — ripple, glitch, dissolve, metal — don't need the plugin at all: they just take the whole card as one texture via plain snapdom.toCanvas().
What you're looking at
The card's heading and paragraph are contenteditable — editable HTML. Whatever you type is what ends up baked into the WebGL texture, because SnapDOM captures the card exactly as it currently renders: your text, your font metrics, the gradient, the shadow. Edit the copy, then apply an effect, and you're watching your own words get shattered, rippled, or dissolved.
Try it
Edit the card, pick an effect, hit Shatter (the button relabels itself per effect). Ripple, Glitch, and Dissolve settle or complete on their own — well, Dissolve needs a manual Reset since it ends empty. Metal stays interactive, tracking your pointer, until you exit it.
Try me!
This text is genuinely editable HTML. SnapDOM captures it exactly as it renders — font, gradient, shadow — and turns it into a WebGL texture.
Edit the card's text before applying an effect — you'll see your own text, not a canned image.
How it works
Ripple, Glitch, Dissolve, Metal: one texture, one shader
snapdom.toCanvas(card, { scale: 2 })captures the card once as a single canvas. That canvas becomes a WebGL texture viatexImage2D, and a per-effect fragment shader distorts or reveals it over a fixed duration — or, for Metal, indefinitely while tracking pointer position.The card hides while the effect plays
The card's CSS
visibilityis set tohiddenright after the texture is captured, and restored once the effect finishes — except Metal, which stays hidden and interactive until you explicitly exit it.Shatter needs more than one texture
The
shatterExportplugin'sdefineExports(ctx)returns ashatterexport function. Callingresult.toShatter({ cols, rows })first gets a canvas viactx.exports.canvas(opts)— the same core canvas exporter, called directly, bypassing the plugin pipeline's own hook re-runs.That canvas is sliced into a grid
The canvas is cut into
cols × rowssmall canvases withdrawImage, each one becoming its own WebGL texture positioned at its origin cell.Physics takes over
Each piece gets a random launch velocity and rotation on spawn; gravity is applied every frame, and pieces fade out until none are left.
Why this needed a plugin
SnapDOM's built-in exports — toPng, toCanvas, toSvg, toBlob — all return one complete image. There's no option to get back "the same capture, pre-sliced into N pieces," because slicing into tiles is a shatter-effect concern, not a general capture concern.
defineExports is exactly the seam SnapDOM exposes for adding an export shape that doesn't exist in core. And because the new shatter export still needs ordinary canvas pixels underneath it, it calls back into the "silent" ctx.exports.{img,svg,canvas,blob,png,jpeg,webp} facade, which reuses the core exporter functions directly without re-running the plugin's own beforeExport/afterExport hooks — avoiding infinite recursion and duplicate hook firing.
The other four effects deliberately don't use a plugin at all, and that's the point: most creative uses of a SnapDOM capture don't need one. defineExports is for the narrower case of inventing a new output shape, not a substitute for reaching for toCanvas() first.
Copy-paste example
The plugin factory — cols/rows control how fine the grid is:
function shatterExport (options = {}) {
const { cols: defaultCols = 14, rows: defaultRows = 9 } = options
return {
name: 'shatter',
defineExports (ctx) {
return {
shatter: async (_ctx2, opts = {}) => {
const cols = opts.cols || defaultCols
const rows = opts.rows || defaultRows
// Reuse the core canvas exporter directly — this does NOT
// re-run beforeExport/afterExport, so it's safe to call from
// inside another export.
const canvas = await ctx.exports.canvas(opts)
const pw = canvas.width / cols
const ph = canvas.height / rows
const pieces = []
for (let ry = 0; ry < rows; ry++) {
for (let rx = 0; rx < cols; rx++) {
const piece = document.createElement('canvas')
piece.width = pw
piece.height = ph
piece.getContext('2d').drawImage(canvas, rx * pw, ry * ph, pw, ph, 0, 0, pw, ph)
pieces.push({ u: rx / cols, v: ry / rows, du: 1 / cols, dv: 1 / rows, canvas: piece })
}
}
return { pieces }
}
}
}
}
}
Usage — the plugin is local to this one capture, and toShatter() shows up as a method on the result alongside the built-in toPng/toCanvas:
const result = await snapdom(card, { plugins: [shatterExport({ cols: 16, rows: 11 })] })
const { pieces } = await result.toShatter()
// pieces[i] = { u, v, du, dv, canvas } — grid position (0..1) plus a
// small <canvas>, ready to hand to texImage2D as its own WebGL texture
The other four effects skip the plugin entirely — one capture, one texture:
const canvas = await snapdom.toCanvas(card, { scale: 2 })
const tex = makeTexture(canvas) // gl.texImage2D(..., canvas)
Limits and things to watch
The ctx.exports.canvas() facade is documented in PLUGIN_SPEC.md. It lets a custom exporter reuse core Canvas output without invoking its own export hooks again. Splitting this capture into a 16×11 grid creates 176 pieces; finer grids add draw calls, textures and memory.
Build your own plugin
defineExports — plus afterClone, afterRender, and the rest of the hook chain this site's other labs use — is documented in the plugin spec.