SnapDOMGitHub★ 8K

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

OptionDefault
excludenone 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-stroke is a real stroke, and background-clip: text becomes the text's own fill.
  • textLength is 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-spacing survives, 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, so srcset resolves to what was actually shown, with object-fit and object-position applied. 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 exclude named, 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-system or system-ui cannot 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-filter does 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 spread is 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.