SnapDOM Plugin Specification v1.0
August 10, 2026 · View on GitHub
The official guide for creating SnapDOM plugins.
What is a Plugin?
A SnapDOM plugin is a plain JavaScript object with a unique name and one or more lifecycle hooks. Plugins can modify the capture at any stage.
const myPlugin = {
name: 'my-plugin',
afterClone(ctx) {
// modify ctx.clone before render
}
};
Plugin Factory Pattern (Recommended)
Wrap your plugin in a factory function to accept options:
export function myPlugin(options = {}) {
const { color = 'red', opacity = 0.5 } = options;
return {
name: 'my-plugin',
afterClone(ctx) {
ctx.clone.style.border = `2px solid ${color}`;
}
};
}
Usage:
import { snapdom } from '@zumer/snapdom';
import { myPlugin } from 'snapdom-plugin-my-plugin';
// Per-capture
const result = await snapdom(element, {
plugins: [myPlugin({ color: 'blue' })]
});
// Global registration
snapdom.plugins(myPlugin());
Lifecycle Hooks
Hooks execute in this order:
beforeSnap → beforeClone → afterClone → beforeRender → afterRender → beforeExport → afterExport
Plus defineExports for adding custom export methods.
| Hook | When it runs | Common use cases |
|---|---|---|
beforeSnap | Before anything happens | Validate options, set defaults |
beforeClone | Before DOM is cloned | Pre-process live DOM (undo in afterClone) |
afterClone | After clone is created | Transform clone: overlays, styles, replacements |
beforeRender | Before SVG serialization | Modify SVG string or rendering options |
afterRender | After SVG is rendered | Post-process rendered output |
beforeExport | Before each export call | Modify export options (quality, type) |
afterExport | After each export call | Transform export output (chained) |
defineExports | During plugin registration | Add new export formats (toPdf, toAscii) |
resolveNode | Per node, during cloning | Replace/skip individual nodes (redaction, custom widgets) |
Per-node hook: resolveNode(node, ctx)
Unlike the lifecycle hooks, resolveNode runs once per source node while the clone is
built (after exclude/filter, before built-in handling of iframe/canvas/video/audio).
The first plugin that returns a value wins:
- Return a Node → used as the finished clone for that node (subtree included). SnapDOM maps it to the source and copies the source's computed box styles onto it, so it keeps the original layout.
- Return
null→ the node is skipped entirely. - Return
undefined→ continue with the normal pipeline.
export function redactEmails() {
return {
name: 'redact-emails',
resolveNode(node, _ctx) {
if (node.nodeType === 1 && node.matches?.('[data-private]')) {
const box = document.createElement('div')
box.textContent = '███'
return box
}
// undefined → normal cloning
},
}
}
Keep it fast: it runs on every node of the captured subtree. Prefer cheap checks
(tagName, an attribute) before anything expensive.
Hook Context
Every hook receives a single context object (ctx):
{
// Input & options
element, // Original DOM element
debug, fast, // Mode flags
scale, dpr, // Resolution
width, height, // Dimensions
backgroundColor, // Background color
quality, // Export quality (0-1)
useProxy, // CORS proxy URL
cache, // Cache instance
outerTransforms, outerShadows,
embedFonts, localFonts, iconFonts, excludeFonts,
exclude, excludeMode,
filter, filterMode,
fallbackURL,
// Intermediate values (available after their stage)
clone, // Cloned DOM tree
classCSS, styleCache,
fontsCSS, baseCSS,
svgString, // After beforeRender
dataURL, // After afterRender
// During export hooks
export: { type, options, requestedOptions, url }
}
During an export, export.options is the normalized merge of capture defaults and
the export call. export.requestedOptions is a frozen shallow copy of exactly what
the caller passed to toXxx(...): key presence is preserved, including explicit
values equal to a capture default. It is snapped synchronously when toXxx() is
called, before that export waits behind any earlier job in the capture's queue.
Plugin exporters should use it when applying their own defaults. In
defineExports, only the final canonical export.url is guaranteed because no
export call is active yet. element remains the original source element in both
defineExports and export-hook contexts, including when it belongs to a
same-origin iframe.
Hook Rules
- Hooks can be sync or async. SnapDOM awaits all hooks.
- Mutate
ctxfreely, e.g. changectx.backgroundColorinbeforeSnap. afterExportreturn values are chained to the next plugin.- DOM mutations in
beforeClonemust be undone. The live page should not be affected.
Adding Custom Exports with defineExports
export function pdfExport(options = {}) {
return {
name: 'pdf-export',
defineExports(ctx) {
return {
pdf: async (ctx, opts) => {
const svgUrl = ctx.export.url;
// convert to PDF...
return pdfBlob;
}
};
}
};
}
// After registration:
const result = await snapdom(element, { plugins: [pdfExport()] });
const blob = await result.toPdf({ width: 800 });
Priority. When multiple sources define the same export key, resolution is local plugin > global plugin > core. So a plugin passed via snapdom(el, { plugins: [...] }) can override toPng, toJpg, toCanvas, etc., and a per-capture plugin beats a globally-registered one with the same key. Use this to swap a core exporter for a plugin implementation (e.g. a plugin-provided png that reuses the existing SVG via ctx.export.url).
defineExports(ctx) also receives ctx.exports, a silent facade over the core
exporters. It reuses this capture without recursively firing export hooks. Its
canvas() method accepts crop: { x, y, width, height } in SVG viewBox
coordinates; SnapDOM windows the SVG before image decode, allowing document
plugins to rasterize page-sized regions instead of one browser-limited bitmap.
A crop is clipped to the intersection with the viewBox, and it never degrades
silently: a non-finite or empty window, a window fully outside the viewBox, or a
payload that is not a serialized SVG capture all reject with a RangeError
rather than returning the whole capture where one page was requested.
The returned capture object exposes the same immutable render geometry as
result.meta; both the metadata value and the result property that holds it are
non-writable/non-configurable. Auxiliary element captures can therefore measure
their own final SVG artifact without consulting the live source tree or risking
URL/geometry drift.
Distribution
Official plugins
Official plugins ship as a separate package to keep the core lightweight:
npm i @zumer/snapdom-plugins
// Individual (tree-shakeable)
import { filter } from '@zumer/snapdom-plugins/filter';
// All at once
import { filter, asciiExport, replaceText } from '@zumer/snapdom-plugins';
They live in packages/plugins/ inside the snapdom monorepo.
Community plugins
Publish to npm with the naming convention:
Package name: snapdom-plugin-[name]
Plugin name field: lowercase kebab-case: 'watermark', 'redact', 'pdf-export'
Plugin Template
/**
* snapdom-plugin-example
* Short description.
*
* @param {Object} options
* @returns {Object} SnapDOM plugin
*/
export function example(options = {}) {
const {
enabled = true,
} = options;
return {
name: 'example',
// Pick only the hooks you need:
// beforeSnap(ctx) {},
// beforeClone(ctx) {},
afterClone(ctx) {
if (!enabled) return;
// modify ctx.clone
},
// beforeRender(ctx) {},
// afterRender(ctx) {},
// beforeExport(ctx) {},
// afterExport(ctx) {},
// defineExports(ctx) { return { format: async (ctx, opts) => {} }; },
};
}
Publishing a Community Plugin
1. Create the package
mkdir snapdom-plugin-yourname && cd $_
npm init -y
2. package.json
{
"name": "snapdom-plugin-yourname",
"version": "1.0.0",
"description": "A SnapDOM plugin that does X",
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js" },
"keywords": ["snapdom", "snapdom-plugin", "dom-capture"],
"peerDependencies": { "@zumer/snapdom": ">=0.9.0" },
"license": "MIT"
}
3. Write, test, publish
npm publish
Then open a PR or issue at zumerlab/snapdom to list it in the plugin directory.
Plugin Categories
| Category | Description | Examples |
|---|---|---|
| Capture | Modify how DOM is captured | pictureResolver, lazy-load handler |
| Transform | Alter cloned output | overlay, filter, redact, watermark |
| Export | Add output formats | PDF, ASCII, AVIF, animated GIF |
| Integration | Connect to external services | upload to S3, post to Slack |
| Utility | Dev tools and helpers | debug overlay, perf timer |
Best Practices
- Be opt-in. Zero overhead when not active.
- Restore the DOM. If you mutate in
beforeClone, undo inafterClone. - Use the factory pattern. Always accept options, always set defaults.
- Name uniquely. Check the directory first.
- Handle errors gracefully.
try/catchyour logic. - Document your options. Type, default, description.
- Keep dependencies minimal. Ideally zero.
- Test with
scale: 2. High-DPI exposes pixel math issues.
Example: Watermark Plugin
export function watermark(options = {}) {
const {
text = '© SnapDOM',
fontSize = 14,
color = 'rgba(0,0,0,0.15)',
position = 'bottom-right',
rotate = -30,
} = options;
return {
name: 'watermark',
afterClone(ctx) {
const overlay = document.createElement('div');
const posStyles = {
'top-left': 'top:8px;left:8px',
'top-right': 'top:8px;right:8px',
'bottom-left': 'bottom:8px;left:8px',
'bottom-right': 'bottom:8px;right:8px',
'center': 'top:50%;left:50%;transform:translate(-50%,-50%)'
};
overlay.style.cssText = `
position:absolute;
${posStyles[position] || posStyles['bottom-right']};
font-size:${fontSize}px;
color:${color};
pointer-events:none;
z-index:999999;
white-space:nowrap;
${position !== 'center' && rotate ? `transform:rotate(${rotate}deg)` : ''}
`;
overlay.textContent = text;
ctx.clone.style.position = 'relative';
ctx.clone.appendChild(overlay);
}
};
}
Questions? Open a Discussion or check the Plugin Directory.