SnapDOM Vector
A SnapDOM plugin. Register it on a capture and that capture gains
toVector(), a standalone SVG, and toFigma(), a copy you paste into
Figma. This page is the whole reference.
Getting started
The Vector plugin and its internal SVD format live in packages/plugins/vector under the MIT license. The plugin package is available on npm in @zumer/snapdom-plugins 4.x and requires SnapDOM 3.x. SnapDOM 3 is a peer dependency; it is not bundled in.
import { snapdom } from '@zumer/snapdom'
import vector from '@zumer/snapdom-plugins/vector'
const shot = await snapdom(document.querySelector('#plan-card'), {
plugins: [vector()]
})
const svg = await shot.toVector() // a standalone SVG string
await shot.toFigma() // on the clipboard: paste it into Figma
Nothing runs on a server and nothing is uploaded. Everything happens in the page you are already looking at, which is the point: the component worth capturing is usually behind a login.
Registering
Register it per capture. Registering it globally costs you something, so here are both forms and the reason to prefer the first:
// this capture only, and the one to use
await snapdom(el, { plugins: [vector()] })
// every capture in the page
snapdom.plugins(vector())
A plugin with render hooks suspends SnapDOM's burst optimisation on the captures it
runs on, because burst answers with a memoized URL without re-entering the capture pipeline,
and then there is no clone for this engine to read. Registered per capture, that cost lands on
the captures that asked for it. Registered globally, it lands on all of them, including the
toPng() in your hot path.
Both methods throw if the captured element has no layout box (an element with
display: none has nothing to measure), and if the capture carries no clone, which
happens when burst: true was forced on the snapdom() call. Leave
burst unset. The captured element must still be connected, with its styles
available, when you export.
Everything about the capture stays on the snapdom()
call. scale, dpr, backgroundColor and the rest are
read before the plugin runs, so there is no second set of names for them here.
exclude
| Option | Default | |
|---|---|---|
| exclude | none | A CSS selector, a predicate (element) => boolean, or an array of either.
Matching elements and their subtrees are left out of the vector output, text and layer
names included. |
It is the only option. Give it to vector({ exclude }) as the default for every
export from that capture, or to a single toVector() or toFigma()
call, where it wins for that call.
const shot = await snapdom(el, { plugins: [vector({ exclude: '.no-print' })] })
const svg = await shot.toVector({ exclude: ['.no-print', (node) => node.dataset.private === 'true'] })
SnapDOM's own exclude on the snapdom() call also applies: anything it
leaves out of the capture never reaches the vector output.
toVector()
shot.toVector(options?) → Promise<string>
A standalone SVG string. No foreignObject, anywhere. An SVG with a
foreignObject in it is an HTML document wearing an SVG hat, and every vector tool
opens it empty. Here a rectangle is a <rect> and a line of text is a
<text>. The file opens in Illustrator, Inkscape, Penpot, Figma and any
browser.
- The browser breaks the lines and the SVG only draws them. A block that occupies more
than one line becomes one
<text>per visual line, each at its measured baseline, so no editor re-wraps your paragraph differently. - Gradients, borders, radii, shadows, transforms, clips and images become native SVG
paint.
-webkit-text-strokeis a real stroke, andbackground-clip: textbecomes the text's own fill. textLengthis written only when a font binary travels with the file. Pinning a line to a width measured with your font, in a viewer that substituted another one, squeezes the glyphs.- Native form controls are reconstructed at Chromium's measured proportions from their kind, state and accent colour, and each one is declared as a reconstruction.
The file is self-describing: the approximations are written into an XML comment under
the <svg> tag, so a file that travels without its report cannot exist.
toFigma()
shot.toFigma(options?) → Promise<void>
Puts the capture on the clipboard in Figma's own paste format. Paste into a Figma file and you get frames, editable text, gradients, shadows, borders and images, with nothing installed. It resolves once the clipboard holds the copy.
button.addEventListener('click', async () => {
await shot.toFigma()
toast('Copied. Paste it into Figma.')
})
- Call it from a user action, on https or localhost. It uses the async clipboard API, and browsers only let a page the user is interacting with write to the clipboard. Outside a secure context it rejects with a message that says so.
letter-spacingsurvives, which Figma's SVG importer cannot do: measured, a 376.8px line of text arrives at 449px through the SVG route, 50px of that from ignored tracking.- Nodes whose paint the paste format cannot spell are drawn as vector artwork and carried as an image, unless they hold text, which stays editable.
- Effects that read what is underneath a node (
mix-blend-mode,backdrop-filter) arrive unblended.
The format is Figma's, and it is undocumented. Figma can change
it in any release, with no error; the only symptom is a paste that does nothing. If that
happens, toVector() and Figma's SVG import keep working while we update.
Diagnostics
Every approximation (a conic gradient drawn as wedges, a shadow spread folded into a blur, a
filter a destination cannot run) is printed to the console under [snapdom-vector],
grouped by code with a count and up to three distinct messages, so four hundred identical
warnings come out as one line and a count. The codes are stable and greppable, and
toVector() also writes the list into the SVG.
A diagnostic that says something will be lost in the destination (the SVG is exactly right in a browser, and Figma runs only part of it) is a warning about where the file is going, not a degradation of the file.
What reaches the output, and what does not
The engine walks the clone SnapDOM already resolved, which is why membership in the tree is not an argument here. In:
- Pseudo-elements (
::before,::after,::first-letter) as real nodes with real boxes, with counters and quotes resolved. - Shadow DOM, flattened, so slotted content appears once, at the slot.
- Icon fonts resolved by ligature, and inline
<svg>carried as vector with its element ids namespaced per instance. - Images from
currentSrc, sosrcsetresolves to what was actually shown, withobject-fitandobject-positionapplied. An SVG source is inlined as vector, not rasterized. - Scroll offsets, applied, and overflow clipping as a real clip.
<canvas>and<video>, as bitmaps. A tainted canvas is omitted with a reason rather than throwing.- In-flight animations, at the values of the frame that was captured.
Out:
- Anything
excludenamed, and anything SnapDOM itself excludes. display: none,visibility: hidden, and content clipped away by an overflow ancestor.- The value inside a native control. The text in an
<input>, the chosen<option>of a<select>and a placeholder are painted by the browser itself and no CSS property describes them. The widget's frame, border and background are drawn. - Cross-origin iframes. The frame becomes a placeholder.
- Closed shadow roots, which are unreadable by anyone.
Limits
Written down because you will meet them, and meeting them here is better than meeting them in front of a client.
- Chromium. Everything is measured there and the pixel baselines were recorded there. Other engines are not tested, and we do not claim them.
- No font binary travels. The output names the family and the face; it does not embed the file. So a viewer without your font substitutes one.
- A system font keyword is not a font name. Text painted by
-apple-systemorsystem-uicannot tell any destination which face to load. - Auto Layout is not inferred. Everything lands positioned. A frame you can move is not a frame that reflows, and guessing wrong at the layout is worse than not guessing.
backdrop-filterdoes not survive. Frosted glass is a blur of whatever sits behind the panel, and neither SVG nor Figma can say that about an arbitrary backdrop. The panel arrives as a flat colour, and the diagnostics name it.- A conic gradient becomes 128 flat wedges plus a small disc capping the apex. SVG has no conic paint server.
- Shadow
spreadis approximate.feMorphology's kernel is rectangular, so the spread corners are. - 3D transforms are flattened to their 2D projection, with a diagnostic.
- Figma has no colour-matrix effect, so
filter: saturate(…)and its relatives have no equivalent there. They are named, with the CSS verbatim, rather than dropped quietly. - The capture is one subtree, one viewport, one moment. No page-wide capture, no responsive variants, no component variants inferred from a static picture.