Skip to content

Visual DOM Snapshots

Capture a visual snapshot of any page as a self-contained HTML file. Walks the live DOM to inline all CSS, strip scripts and dynamic behavior, then copies to clipboard and downloads automatically.

When to use

Use visual snapshots when you need a self-contained copy of a page's rendered appearance. Common cases include capturing the current state for a bug report, exporting into a design tool, running visual regression tests against a baseline HTML file, and archiving a rendered page without a server.

The pattern

Copy the entire block below — function definition and invocation — and paste it into the browser console. It walks the DOM, inlines stylesheets, embeds images, strips scripts and event handlers, stamps metadata, copies the result to the clipboard, and downloads it as an HTML file.

javascript
/**
 * Walk the live DOM, emit a clean static snapshot as self-contained HTML.
 * Copies to clipboard and downloads automatically.
 *
 * Usage (browser console):
 *   dom2html()
 */
async function dom2html() {
  const span = createSpan("dom2html");
  const pendingSheets = [];
  const snapshot = buildSnapshot(
    document.documentElement, pendingSheets,
  );
  await fetchOriginalStylesheets(pendingSheets);
  inlineAdoptedStyleSheets(snapshot);
  addMetadata(snapshot);

  const html = serialize(snapshot);
  copyToClipboard(html);
  await download(html, snapshotFilename());
  
  span.end();
  return html;
  
  /**
   * Walk the live DOM depth-first. Each node is routed to the first matching
   * layer.
   */
  function buildSnapshot(documentElement, pendingSheets) {
    const root = documentElement.cloneNode(false);
    const walker = document.createTreeWalker(
      documentElement,
      NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
    );
    const nodeMap = new Map([[documentElement, root]]);
    let removed = 0;
    let stylesheets = 0;
    let handlers = 0;
    let imagesFound = 0;
    let imagesEmbedded = 0;
    let canvases = 0;
    let formControls = 0;
    
    const router = createRouter();
    router.use(Node.TEXT_NODE, copyTextNode);
    router.use("link[rel=stylesheet]", inlineStylesheet);
    router.use("script, iframe, link", stripNonVisual);
    router.use("canvas", copyCanvasToImage);
    router.use("style", cloneStyle);
    router.use("img", cloneImage);
    router.use("input", cloneInput);
    router.use("textarea", cloneTextarea);
    router.use("option", cloneOption);
    router.use(clone);
    
    let node;
    while ((node = walker.nextNode())) {
      const parent = nodeMap.get(node.parentNode);
      if (!parent) continue;
      const element = router.run(node);
      if (!element) continue;
      parent.appendChild(element);
      nodeMap.set(node, element);
    }
    
    span.set("elements.removed", removed);
    span.set("stylesheets.inlined", stylesheets);
    span.set("handlers.stripped", handlers);
    span.set("images.found", imagesFound);
    span.set("images.embedded", imagesEmbedded);
    span.set("canvases.captured", canvases);
    span.set("form_controls.captured", formControls);
    return root;
    
    function copyTextNode(node) {
      return document.createTextNode(node.textContent);
    }
    
    function stripNonVisual() {
      removed++;
      return false;
    }
    
    function inlineStylesheet(node) {
      const href = node.href;
      if (!href) return node.cloneNode(false);
      const style = document.createElement("style");
      pendingSheets.push({ href, style });
      return style;
    }
    
    function copyCanvasToImage(node) {
      try {
        const img = document.createElement("img");
        img.src = node.toDataURL();
        img.width = node.width;
        img.height = node.height;
        img.style.cssText = node.style.cssText;
        for (const cssClass of node.classList) {
          img.classList.add(cssClass);
        }
        canvases++;
        return img;
      } catch {
        span.event("tainted canvas (cross-origin data)");
        return false;
      }
    }
    
    function cloneStyle(node) {
      const style = clone(node);
      if (node.textContent) {
        style.textContent = node.textContent;
        return style;
      }
      if (!node.sheet) return style;
      try {
        const cssContent = [...node.sheet.cssRules]
          .map((rule) => rule.cssText).join("\n\n");
        if (cssContent) {
          style.textContent = cssContent;
        }
      } catch {
        // Cross-origin sheet
      }
      return style;
    }
    
    function cloneImage(node) {
      const img = clone(node);
      imagesFound++;
      try {
        const canvas = document.createElement("canvas");
        canvas.width = node.naturalWidth;
        canvas.height = node.naturalHeight;
        canvas.getContext("2d").drawImage(node, 0, 0);
        img.src = canvas.toDataURL();
        imagesEmbedded++;
      } catch {
        img.dataset.crossOrigin = "true";
        span.event(`cross-origin image: ${node.src}`);
      }
      return img;
    }
    
    function cloneInput(node) {
      const input = clone(node);
      if (
        node.type === "checkbox" ||
        node.type === "radio"
      ) {
        if (node.checked) {
          input.setAttribute("checked", "");
        } else {
          input.removeAttribute("checked");
        }
      } else if (node.type !== "file") {
        input.setAttribute("value", node.value);
      }
      formControls++;
      return input;
    }
    
    function cloneTextarea(node) {
      const textarea = clone(node);
      textarea.textContent = node.value;
      formControls++;
      return textarea;
    }
    
    function cloneOption(node) {
      const option = clone(node);
      if (node.selected) {
        option.setAttribute("selected", "");
      } else {
        option.removeAttribute("selected");
      }
      return option;
    }
    
    function clone(node) {
      const element = node.cloneNode(false);
      sanitizeAttributes(element);
      return element;
    }
    
    function sanitizeAttributes(element) {
      for (const attr of [...element.attributes]) {
        if (attr.name.startsWith("on")) {
          element.removeAttribute(attr.name);
          handlers++;
        } else if (attr.name === "nonce") {
          element.removeAttribute("nonce");
        }
      }
      const href = element.getAttribute?.("href");
      if (href?.startsWith("javascript:")) {
        element.setAttribute("href", "javascript:void(0)");
        handlers++;
      }
    }
  }
  
  async function fetchOriginalStylesheets(pending) {
    await Promise.all(pending.map(async ({ href, style }) => {
      try {
        const res = await fetch(href, {
          cache: "force-cache",
        });
        if (res.ok) {
          style.textContent = await res.text();
          span.event(
            `stylesheet: ${new URL(href).pathname}`,
          );
          return;
        }
      } catch {}
      try {
        const sheet = [...document.styleSheets].find(
          (s) => s.href === href,
        );
        const css = [...sheet.cssRules]
          .map((rule) => rule.cssText).join("\n\n");
        style.textContent = css;
        span.event(
          `stylesheet (cssRules): `
          + new URL(href).pathname,
        );
      } catch {
        span.event(
          `stylesheet (cross-origin): ${href}`,
        );
      }
    }));
  }

  function createRouter() {
    /**
     * First match wins. Supports numbers (nodeType), selector strings, test
     * functions, or omit for a catch-all.
     */
    class Router {
      layers = [];
      
      use(...args) {
        if (args.length === 1) {
          const [handler] = args;
          this.layers.push({
            test() { return true; },
            handler,
          });
          
          return;
        }
        
        if (args.length >= 2) {
          const [test, handler] = args;
          
          if (Object.values(Node).includes(test)) {
            this.layers.push({
              test(node) { return node.nodeType === test; },
              handler,
            });
            return;
          }
          
          if (typeof test === "string") {
            this.layers.push({
              test(node) { return node.matches?.(test); },
              handler,
            });
            return;
          }
          
          this.layers.push({ test, handler });
        }
      }
      
      run(node) {
        for (const layer of this.layers) {
          if (layer.test(node)) {
            return layer.handler(node);
          }
        }
      }
    }
    
    return new Router();
  }
  
  function inlineAdoptedStyleSheets(root) {
    const body = root.querySelector("body") ?? root;
    let count = 0;
    
    for (const sheet of document.adoptedStyleSheets) {
      try {
        const css = [...sheet.cssRules]
          .map((rule) => rule.cssText).join("\n\n");
        if (css) {
          const style = document.createElement("style");
          style.setAttribute(
            "data-source", "adoptedStyleSheets",
          );
          style.textContent = css;
          body.appendChild(style);
          count++;
        }
      } catch {
        span.event("inaccessible adopted stylesheet");
      }
    }
    
    if (count) {
      span.event(`inlined ${count} adopted stylesheets`);
    }
  }
  
  function addMetadata(root) {
    const head = root.querySelector("head") ??
      root.insertBefore(
        document.createElement("head"),
        root.firstChild,
      );
      
    function meta(name, content) {
      const el = document.createElement("meta");
      el.setAttribute("name", name);
      el.setAttribute("content", content);
      head.appendChild(el);
    }
    
    meta("snapshot-date", new Date().toISOString());
    meta("snapshot-viewport", `${innerWidth}x${innerHeight}`);
    meta("snapshot-url", location.href);
    span.event("metadata added");
  }
  
  function createSpan(name) {
    const fields = new Map();
    const events = [];
    const start = performance.now();
    
    return {
      set(key, value) {
        fields.set(key, value);
      },
      event(message) {
        events.push({
          ms: Math.round(performance.now() - start),
          message,
        });
      },
      end() {
        const entry = Object.fromEntries(fields);
        entry["span.name"] = name;
        entry.duration_ms = Math.round(
          performance.now() - start,
        );
        if (events.length) {
          entry.events = events;
        }
        console.group(name);
        console.log(entry);
        console.groupEnd();
      },
    };
  }
  
  function serialize(root) {
    const html = `<!DOCTYPE html>\n${root.outerHTML}`;
    span.set("html.size_kb", Math.round(html.length / 1024));
    span.event("serialized");
    return html;
  }
  
  async function download(html, filename) {
    const blob = new Blob([html], { type: "text/html" });
    const anchor = document.createElement("a");
    anchor.href = URL.createObjectURL(blob);
    anchor.download = filename;
    anchor.style.display = "none";
    document.body.appendChild(anchor);
    anchor.click();
    await delay(1000);
    anchor.remove();
    URL.revokeObjectURL(anchor.href);
    span.event(`download: ${filename}`);
    
    async function delay(ms) {
      const { promise, resolve } = Promise.withResolvers();
      setTimeout(resolve, ms);
      return await promise;
    }
  }
  
  function copyToClipboard(text) {
    const textarea = document.createElement("textarea");
    textarea.value = text;
    textarea.style.cssText = "position:fixed;opacity:0";
    document.body.appendChild(textarea);
    textarea.select();
    try {
      document.execCommand("copy");
      span.event("copied to clipboard");
    } catch {
      span.event("clipboard copy failed");
    }
    textarea.remove();
  }
  
  function snapshotFilename() {
    const queryString = /[;?].*$/;
    const trailingSlashes = /\/+$/;
    const leadingSlash = /^\//;
    const pathSlashes = /\//g;
    const unsafeChars = /[\\?%*|"<>]/g;
    const repeatedUnderscores = /_+/g;
    
    const path = location.pathname
      .replace(queryString, "")
      .replace(trailingSlashes, "");
    const segments = path
      .replace(leadingSlash, "")
      .replace(pathSlashes, ".")
      .replace(unsafeChars, "_")
      .replace(repeatedUnderscores, "_");
    const name = (segments ? `${location.host}:${segments}`: location.host)
      .slice(0, 80);
    const timestamp = new Date()
      .toISOString()
      .replace(/:/g, "-")
      .slice(0, 19);
    
    return `${name}.${timestamp}.snapshot.html`;
  }
}

dom2html();

How it works

The snapshot is built with a TreeWalker depth-first walk rather than a bulk cloneNode(true). Each node passes through a router that dispatches to the first matching handler:

MatchHandlerEffect
Node.TEXT_NODEcopyTextNodeCopy text as-is
link[rel=stylesheet]inlineStylesheetCache-read source → <style>
script, iframe, linkstripNonVisualDrop entirely
canvascopyCanvasToImagetoDataURL()<img>
stylecloneStyleCopy textContent or cssRules
imgcloneImageDraw to canvas → data URL
inputcloneInputPersist value/checked state
textareacloneTextareaPersist current value
optioncloneOptionPersist selected state
(catch-all)clonecloneNode(false) + sanitize attrs

After the walk, linked stylesheets are fetched from the browser cache in parallel, adopted stylesheets are appended as <style> blocks, and metadata <meta> tags are stamped into <head>.

Why original source instead of cssRules

The CSSOM cannot round-trip CSS shorthands that contain var(). For example, border: var(--borderWidth-thin, .0625rem) solid is serialized by rule.cssText into empty longhand values like border-top-style: ;. The browser drops these as invalid, causing elements to fall back to UA defaults (e.g. 2px outset on buttons instead of 1px solid).

Reading the original source — via fetch(href, { cache: "force-cache" }) for linked stylesheets and node.textContent for inline styles — preserves shorthands exactly as authored. The CSSOM cssRules serialization is used only as a fallback when the original source is unavailable.

Trade-offs

ApproachProsCons
DOM clone + CSSSelf-containedCross-origin gaps
window.print()Built-inNo programmatic ctrl
Puppeteer/PlaywrightPixel-perfect imageNeeds headless
Raw outerHTMLOne-linerLoses computed styles

Works best on same-origin pages where all stylesheets are accessible. For cross-origin content, combine with a proxy or accept some CSS gaps.