Labs Experiment

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.

TL;DR

A custom defineExports plugin (shatterExport) adds a genuinely new 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 — genuinely editable HTML, not a canned image behind a fake cursor. 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.

Live DOM

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.

100%SnapDOM

Edit the card's text before applying an effect — you'll see your own text, not a canned image.

How it works

  1. 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 via texImage2D, and a per-effect fragment shader distorts or reveals it over a fixed duration — or, for Metal, indefinitely while tracking pointer position.

  2. The card hides while the effect plays

    The card's CSS visibility is set to hidden right after the texture is captured, and restored once the effect finishes — except Metal, which stays hidden and interactive until you explicitly exit it.

  3. Shatter needs more than one texture

    The shatterExport plugin's defineExports(ctx) returns a shatter export function. Calling result.toShatter({ cols, rows }) first gets a canvas via ctx.exports.canvas(opts) — the same core canvas exporter, called directly, bypassing the plugin pipeline's own hook re-runs.

  4. That canvas is sliced into a grid

    The canvas is cut into cols × rows small canvases with drawImage, each one becoming its own WebGL texture positioned at its origin cell.

  5. 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 , 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)
card snapdom(plugins) result.toShatter() ctx.exports.canvas() slice into pieces WebGL textures

Limits and things to watch

ctx.exports.canvas() is an internal facade — not yet part of the public PLUGIN_SPEC.md hook table, but a real, stable pattern in the codebase, meant specifically for plugins that need to build on a core export without re-triggering the whole export pipeline. Slicing one large canvas into many small ones has a real per-piece drawImage cost: this demo's 16×11 grid is already 176 pieces, and pushing much finer than that will start to show on lower-end GPUs.

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.

Explore pluginsRead PLUGIN_SPEC.md