Vue 3 Guide
zumerlab/snapdom

Capture HTML to PNG in Vue 3

A copy-paste single-file component using the Composition API, plus the gotchas to watch out for.

TL;DR

Bind a template ref with ref="cardRef", install @zumer/snapdom, and call snapdom.toPng(cardRef.value) from a button handler. SnapDOM returns an HTMLImageElement ready to append.

1. Install

npm install @zumer/snapdom

Zero runtime dependencies, ESM-only. Works with Vite, Vue CLI and Nuxt out of the box.

2. Minimal capture component (Composition API)

<script setup>
import { ref } from 'vue';
import { snapdom } from '@zumer/snapdom';

const cardRef = ref(null);

async function capture() {
  if (!cardRef.value) return;
  const img = await snapdom.toPng(cardRef.value, { scale: 2 });
  document.body.appendChild(img);
}
</script>

<template>
  <div>
    <div ref="cardRef" class="card">
      <h3>Hello SnapDOM</h3>
      <p>This whole div will be captured.</p>
    </div>
    <button @click="capture">Capture</button>
  </div>
</template>

That's the entire component. The template ref name (cardRef) must match the variable declared in the script setup.

3. Download instead of appending

async function download() {
  if (!cardRef.value) return;
  const result = await snapdom(cardRef.value);
  await result.download({ format: 'png', filename: 'card' });
}

4. Render a live preview

Capture to a Blob and create an object URL to use as the preview <img> source:

<script setup>
import { ref } from 'vue';
import { snapdom } from '@zumer/snapdom';

const cardRef = ref(null);
const previewUrl = ref('');

async function capture() {
  if (!cardRef.value) return;
  const blob = await snapdom.toBlob(cardRef.value);
  previewUrl.value = URL.createObjectURL(blob);
}
</script>

<template>
  <div ref="cardRef"></div>
  <button @click="capture">Capture</button>
  <img v-if="previewUrl" :src="previewUrl" alt="preview" />
</template>

Revoke the object URL with URL.revokeObjectURL(previewUrl.value) when you're done with it to avoid leaks.

5. Reusable composable

// useSnapdom.js
import { ref } from 'vue';
import { snapdom } from '@zumer/snapdom';

export function useSnapdom(options = {}) {
  const target = ref(null);

  async function capture(format = 'png') {
    if (!target.value) return null;
    const result = await snapdom(target.value, options);
    return result.to(format);
  }

  return { target, capture };
}
<script setup>
import { useSnapdom } from './useSnapdom';

const { target, capture } = useSnapdom({ scale: 2, embedFonts: true });
</script>

<template>
  <div ref="target"></div>
  <button @click="capture('png')">Capture</button>
</template>

Common gotchas

The captured image is blank

Make sure you trigger the capture from a user event (click) or inside onMounted — never directly inside setup(). The element ref is only assigned after the component has been mounted.

Also confirm the captured element isn't v-if'd off the page. SnapDOM needs the element to be in the live DOM tree.

Web fonts aren't being captured

Pass { embedFonts: true }. SnapDOM will inline the matching @font-face declarations into the SVG so the captured image renders with your real fonts.

How do I use SnapDOM with the Options API?

Bind ref="cardRef" in the template and access this.$refs.cardRef in a method:

const img = await snapdom.toPng(this.$refs.cardRef);

Does SnapDOM work with Nuxt?

Yes — but only on the client side. Wrap your capture component in <ClientOnly> or import SnapDOM dynamically inside onMounted so it never runs on the server.

Cross-origin images don't render

Use the useProxy option pointing to a CORS proxy: { useProxy: 'https://your-proxy.example.com/' }.

Try it in your project

Drop SnapDOM into your Vue app and capture your first component in under a minute.

Open the demo Install from npm