Skip to content

Instantly share code, notes, and snippets.

@nrrb
Created March 5, 2026 20:20
Show Gist options
  • Select an option

  • Save nrrb/eb6e25bbeabf633fa0c323cdd9e8e647 to your computer and use it in GitHub Desktop.

Select an option

Save nrrb/eb6e25bbeabf633fa0c323cdd9e8e647 to your computer and use it in GitHub Desktop.
Web App to Figma

Web Page → Figma Snapshot Import

Screenshot background + editable rectangles (fills/borders/shadows) + editable text

Goal

Capture a web page “as-is” (at the time of capture) and import it into Figma with minimal manual work while retaining meaningful editability:

  • Pixel-perfect visual baseline via a PNG screenshot (locked background)

  • Editable rectangles for DOM elements with:

    • solid background fills
    • borders (strokes)
    • corner radii
    • box-shadows (drop + inner)
  • Editable text layers positioned to match the screenshot

This workflow does not attempt to reconstruct full responsive layouts, variants/states, or component systems. It is a “snapshot reproduction.”


What you need

  • Google Chrome (or Chromium)

  • Figma Desktop (recommended for local plugin development)

  • Permission to run:

    • JavaScript in the Chrome DevTools Console
    • Figma “Development” plugins

Workflow overview

  1. Stabilize the page (optional): disable animations/transitions for a clean capture.

  2. Capture screenshot: viewport or full-page PNG.

  3. Export snapshot JSON: run a console script to extract rectangles + text from the DOM.

  4. Import into Figma using a local plugin:

    • creates a frame
    • places screenshot as tiled background (handles oversized images)
    • creates rectangles + text layers from the JSON
  5. Tune filters if too many rectangles are generated.


Step 1 — Stabilize the page (optional but recommended)

Run in Chrome DevTools Console to stop animations and transitions:

(() => {
  const style = document.createElement('style');
  style.setAttribute('data-figma-capture', 'true');
  style.textContent = `
    *, *::before, *::after {
      animation: none !important;
      transition: none !important;
      caret-color: transparent !important;
    }
  `;
  document.head.appendChild(style);
  console.log('Animations/transitions disabled for capture.');
})();

Step 2 — Take the screenshot (PNG)

Use DevTools Command Menu:

  • Ctrl+Shift+P / Cmd+Shift+P

  • Choose one:

    • Capture screenshot (viewport)
    • Capture full size screenshot (full page)

Save as PNG.

Note on full-page screenshots: Some pages exceed typical image limits. The provided Figma plugin automatically tiles images larger than 4096×4096 into multiple image rectangles.


Step 3 — Export snapshot JSON (rectangles + borders + shadows + text)

Run the following in Chrome DevTools Console on the page at the time of capture.

Console Script (v2)

Key settings at the top:

  • MIN_RECT_AREA: increase to reduce the number of rectangle layers
  • MAX_LAYERS: safety cap to prevent generating extremely large files
(() => {
  // ======= TUNABLE SETTINGS =======
  const INCLUDE_TEXT = true;
  const INCLUDE_RECTS = true;

  // Filters to prevent “rectangle explosion”
  const MIN_RECT_AREA = 25;       // px^2 (e.g., 25 = 5x5). Increase to reduce layer count.
  const MAX_LAYERS = 12000;       // safety cap
  const IGNORE_TAGS = new Set(['HTML','HEAD','BODY','SCRIPT','STYLE','NOSCRIPT','META','LINK','TITLE']);
  const IGNORE_INPUT_TAGS = new Set(['INPUT','TEXTAREA','SELECT','OPTION']);

  // ======= HELPERS =======
  const css = (el) => window.getComputedStyle(el);

  const normalizeText = (s) => (s || '').replace(/\s+/g, ' ').trim();

  const parsePx = (v) => {
    if (v == null) return null;
    const s = String(v).trim();
    const n = parseFloat(s);
    return Number.isFinite(n) ? n : null;
  };

  const parseRadiusPx = (v) => {
    // computed style can be "4px" or "4px 4px" (elliptical radii)
    if (!v) return 0;
    const first = String(v).trim().split(/\s+/)[0];
    return parsePx(first) ?? 0;
  };

  function splitTopLevelCommas(str) {
    // Splits "a, b(r,g), c" into ["a", "b(r,g)", "c"]
    const out = [];
    let depth = 0;
    let cur = '';
    for (const ch of String(str)) {
      if (ch === '(') depth++;
      if (ch === ')') depth = Math.max(0, depth - 1);
      if (ch === ',' && depth === 0) {
        out.push(cur.trim());
        cur = '';
      } else {
        cur += ch;
      }
    }
    if (cur.trim()) out.push(cur.trim());
    return out;
  }

  function parseBoxShadow(shadowStr) {
    const s = String(shadowStr || '').trim();
    if (!s || s === 'none') return [];

    const parts = splitTopLevelCommas(s);
    const shadows = [];

    for (let p of parts) {
      const inset = /\binset\b/i.test(p);

      // color: computed style usually gives rgb/rgba(...)
      const colorMatch = p.match(/rgba?\([^)]+\)/i) || p.match(/#[0-9a-f]{3,8}/i);
      const color = colorMatch ? colorMatch[0] : 'rgba(0,0,0,1)';

      // remove color + inset, remaining are lengths
      p = p.replace(color, '').replace(/\binset\b/i, '').trim();
      const tokens = p.split(/\s+/).filter(Boolean);

      // CSS: offset-x offset-y [blur] [spread]
      const offsetX = parsePx(tokens[0]) ?? 0;
      const offsetY = parsePx(tokens[1]) ?? 0;
      const blur = parsePx(tokens[2]) ?? 0;
      const spread = parsePx(tokens[3]) ?? 0;

      shadows.push({ inset, offsetX, offsetY, blur, spread, color });
    }

    return shadows;
  }

  function rgbaAlpha(colorStr) {
    const s = String(colorStr || '').trim().toLowerCase();
    if (!s) return 0;
    if (s === 'transparent') return 0;

    const m = s.match(/^rgba?\((.+)\)$/);
    if (m) {
      const parts = m[1].split(',').map(x => x.trim());
      if (parts.length >= 4) {
        const a = parseFloat(parts[3]);
        return Number.isFinite(a) ? a : 1;
      }
      return 1;
    }
    if (s.startsWith('#')) {
      const hex = s.slice(1);
      if (hex.length === 8) return parseInt(hex.slice(6, 8), 16) / 255;
      return 1;
    }
    return 1;
  }

  const isVisible = (el) => {
    const s = css(el);
    if (s.display === 'none') return false;
    if (s.visibility === 'hidden') return false;
    if (parseFloat(s.opacity || '1') === 0) return false;

    const r = el.getBoundingClientRect();
    if (r.width < 1 || r.height < 1) return false;
    return true;
  };

  const isTextLeaf = (el) => {
    if (!el) return false;
    if (IGNORE_TAGS.has(el.tagName)) return false;
    if (IGNORE_INPUT_TAGS.has(el.tagName)) return false;
    if (el.children && el.children.length > 0) return false;

    const text = normalizeText(el.innerText || el.textContent);
    if (!text) return false;

    const s = css(el);
    if (!s) return false;

    const c = String(s.color || '').toLowerCase();
    if (c === 'transparent' || c === 'rgba(0, 0, 0, 0)') return false;

    return true;
  };

  const shouldCaptureRect = (el) => {
    if (IGNORE_TAGS.has(el.tagName)) return false;
    if (!isVisible(el)) return false;

    const r = el.getBoundingClientRect();
    const area = r.width * r.height;
    if (area < MIN_RECT_AREA) return false;

    const s = css(el);

    const bgAlpha = rgbaAlpha(s.backgroundColor);
    const hasSolidBg = (s.backgroundImage === 'none') && bgAlpha > 0.01;

    const bwTop = parsePx(s.borderTopWidth) ?? 0;
    const bwRight = parsePx(s.borderRightWidth) ?? 0;
    const bwBottom = parsePx(s.borderBottomWidth) ?? 0;
    const bwLeft = parsePx(s.borderLeftWidth) ?? 0;
    const hasAnyBorderWidth = (bwTop + bwRight + bwBottom + bwLeft) > 0;

    const bs = String(s.boxShadow || '').trim();
    const hasShadow = bs && bs !== 'none';

    // If border widths exist but style is none/hidden everywhere, treat as no border
    const borderStyles = [s.borderTopStyle, s.borderRightStyle, s.borderBottomStyle, s.borderLeftStyle]
      .map(x => String(x || '').toLowerCase());
    const hasBorderStyle = borderStyles.some(st => st && st !== 'none' && st !== 'hidden');

    const hasBorder = hasAnyBorderWidth && hasBorderStyle;

    // Capture only if it paints something meaningful
    return hasSolidBg || hasBorder || hasShadow;
  };

  const firstFontFamily = (fontFamily) =>
    (fontFamily || 'Inter')
      .split(',')[0]
      .trim()
      .replace(/^["']|["']$/g, '');

  // ======= CAPTURE =======
  const layers = [];
  const all = Array.from(document.body.querySelectorAll('*'));
  let pushed = 0;

  for (let i = 0; i < all.length; i++) {
    const el = all[i];
    if (pushed >= MAX_LAYERS) break;

    // Rect layers (background/border/shadow)
    if (INCLUDE_RECTS && shouldCaptureRect(el)) {
      const s = css(el);
      const r = el.getBoundingClientRect();

      const x = r.left + window.scrollX;
      const y = r.top + window.scrollY;

      const fill = (s.backgroundImage === 'none' && rgbaAlpha(s.backgroundColor) > 0.01)
        ? s.backgroundColor
        : null;

      const border = {
        top:    { width: parsePx(s.borderTopWidth) ?? 0,    color: s.borderTopColor,    style: s.borderTopStyle },
        right:  { width: parsePx(s.borderRightWidth) ?? 0,  color: s.borderRightColor,  style: s.borderRightStyle },
        bottom: { width: parsePx(s.borderBottomWidth) ?? 0, color: s.borderBottomColor, style: s.borderBottomStyle },
        left:   { width: parsePx(s.borderLeftWidth) ?? 0,   color: s.borderLeftColor,   style: s.borderLeftStyle },
      };

      const radii = {
        tl: parseRadiusPx(s.borderTopLeftRadius),
        tr: parseRadiusPx(s.borderTopRightRadius),
        br: parseRadiusPx(s.borderBottomRightRadius),
        bl: parseRadiusPx(s.borderBottomLeftRadius),
      };

      const shadows = parseBoxShadow(s.boxShadow);

      layers.push({
        type: 'rect',
        order: i,
        name: `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}`,
        x, y,
        width: r.width,
        height: r.height,
        opacity: s.opacity,
        fill,       // css color string or null
        border,     // per-side widths/colors
        radii,      // per-corner px
        shadows,    // parsed box-shadow list
      });
      pushed++;
    }

    // Text layers
    if (INCLUDE_TEXT && isVisible(el) && isTextLeaf(el)) {
      const s = css(el);
      const r = el.getBoundingClientRect();

      layers.push({
        type: 'text',
        order: i,
        name: `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}`,
        x: r.left + window.scrollX,
        y: r.top + window.scrollY,
        width: r.width,
        height: r.height,
        characters: normalizeText(el.innerText || el.textContent),
        style: {
          fontFamily: firstFontFamily(s.fontFamily),
          fontSize: s.fontSize,
          fontWeight: s.fontWeight,
          fontStyle: s.fontStyle,
          lineHeight: s.lineHeight,
          letterSpacing: s.letterSpacing,
          textAlign: s.textAlign,
          textTransform: s.textTransform,
          textDecorationLine: s.textDecorationLine,
        },
        color: s.color,
        opacity: s.opacity,
      });
      pushed++;
    }
  }

  // Ensure deterministic stacking order
  layers.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));

  const snapshot = {
    meta: {
      url: location.href,
      title: document.title,
      capturedAt: new Date().toISOString(),
      viewport: {
        width: window.innerWidth,
        height: window.innerHeight,
        devicePixelRatio: window.devicePixelRatio,
      },
      page: {
        scrollX: window.scrollX,
        scrollY: window.scrollY,
        fullWidth: document.documentElement.scrollWidth,
        fullHeight: document.documentElement.scrollHeight,
      },
      limits: { MIN_RECT_AREA, MAX_LAYERS, INCLUDE_TEXT, INCLUDE_RECTS }
    },
    layers,
  };

  const json = JSON.stringify(snapshot, null, 2);

  const download = () => {
    const blob = new Blob([json], { type: 'application/json' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `figma-snapshot-${Date.now()}.json`;
    a.click();
    URL.revokeObjectURL(a.href);
    console.log('Downloaded snapshot JSON.');
  };

  if (navigator.clipboard?.writeText) {
    navigator.clipboard.writeText(json).then(
      () => console.log('Snapshot JSON copied to clipboard.'),
      () => download()
    );
  } else {
    download();
  }

  console.log(snapshot);
})();

Outputs:

  • page.png (the screenshot)
  • figma-snapshot-<timestamp>.json (or JSON copied to clipboard)

Step 4 — Import into Figma with a Development plugin

4.1 Create the plugin

In Figma Desktop:

  1. Plugins → Development → New Plugin…
  2. Choose Create new plugin
  3. Choose any name, e.g. Web Snapshot Import
  4. Choose With UI (recommended)
  5. Open the created plugin folder and replace code.js with the file below.

4.2 Plugin code (code.js)

Paste the following as your entire code.js.

// Web Snapshot Import (v2) — screenshot background + editable rectangles + editable text
//
// Creates Rectangle nodes for elements with background/border/shadow.
// - Rectangle supports per-corner radii and per-side stroke weights.
// - strokeAlign supports 'CENTER' | 'INSIDE' | 'OUTSIDE'.
// - effects support drop/inner shadows with offset/radius/color and optional spread.
// - Solid paints support opacity.

const UI_HTML = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <style>
    body { font: 12px/1.4 sans-serif; margin: 12px; }
    .row { margin-bottom: 10px; }
    label { display: block; font-weight: 600; margin-bottom: 4px; }
    input[type="file"], textarea, select { width: 100%; }
    textarea { height: 140px; font-family: ui-monospace, Menlo, monospace; }
    .btns { display: flex; gap: 8px; margin-top: 10px; }
    button { padding: 8px 10px; }
    .small { color: #555; }
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
    .status { white-space: pre-wrap; background: #f6f6f6; padding: 8px; border-radius: 6px; }
  </style>
</head>
<body>
  <div class="row">
    <label>Screenshot PNG</label>
    <input id="png" type="file" accept="image/png" />
    <div class="small">Viewport or full-page PNG. Oversized images will be tiled automatically.</div>
  </div>

  <div class="row">
    <label>Snapshot JSON</label>
    <input id="jsonFile" type="file" accept="application/json" />
    <div class="small">Optional if you only want screenshot import.</div>
  </div>

  <div class="row">
    <label>Or paste Snapshot JSON</label>
    <textarea id="jsonText" placeholder="{ ... }"></textarea>
  </div>

  <div class="row grid">
    <div>
      <label>Screenshot type</label>
      <select id="shotType">
        <option value="viewport">Viewport (at captured scroll position)</option>
        <option value="full">Full page (origin at 0,0)</option>
      </select>
    </div>
    <div>
      <label>Scale</label>
      <select id="scaleMode">
        <option value="auto">Auto (recommended)</option>
        <option value="1">1.0</option>
        <option value="2">2.0</option>
      </select>
      <div class="small">Auto infers from screenshot pixels vs CSS pixels.</div>
    </div>
  </div>

  <div class="row grid">
    <div>
      <label><input id="lockBg" type="checkbox" checked /> Lock screenshot background</label>
    </div>
    <div>
      <label><input id="importRects" type="checkbox" checked /> Import rectangles (fills/borders/shadows)</label>
    </div>
  </div>

  <div class="row grid">
    <div>
      <label><input id="importText" type="checkbox" checked /> Import text layers</label>
    </div>
    <div>
      <label><input id="groupLayers" type="checkbox" checked /> Group imported layers</label>
    </div>
  </div>

  <div class="btns">
    <button id="import">Import</button>
    <button id="cancel">Close</button>
  </div>

  <div class="row">
    <label>Status</label>
    <div id="status" class="status">Idle.</div>
  </div>

<script>
  const MAX_TILE = 4096;

  const $ = (id) => document.getElementById(id);
  const statusEl = $('status');

  function setStatus(msg) {
    statusEl.textContent = msg;
  }

  function readFileAsArrayBuffer(file) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onerror = () => reject(fr.error);
      fr.onload = () => resolve(fr.result);
      fr.readAsArrayBuffer(file);
    });
  }

  function readFileAsText(file) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onerror = () => reject(fr.error);
      fr.onload = () => resolve(fr.result);
      fr.readAsText(file);
    });
  }

  async function getPngInfoAndTiles(file) {
    const arrayBuffer = await readFileAsArrayBuffer(file);
    const blob = new Blob([arrayBuffer], { type: 'image/png' });
    const url = URL.createObjectURL(blob);

    const img = await new Promise((resolve, reject) => {
      const i = new Image();
      i.onload = () => resolve(i);
      i.onerror = reject;
      i.src = url;
    });

    const fullWidth = img.naturalWidth;
    const fullHeight = img.naturalHeight;

    if (fullWidth <= MAX_TILE && fullHeight <= MAX_TILE) {
      URL.revokeObjectURL(url);
      return {
        fullWidth, fullHeight,
        tiles: [{ x: 0, y: 0, width: fullWidth, height: fullHeight, bytes: arrayBuffer }]
      };
    }

    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    const tiles = [];
    for (let y = 0; y < fullHeight; y += MAX_TILE) {
      for (let x = 0; x < fullWidth; x += MAX_TILE) {
        const w = Math.min(MAX_TILE, fullWidth - x);
        const h = Math.min(MAX_TILE, fullHeight - y);

        canvas.width = w;
        canvas.height = h;
        ctx.clearRect(0, 0, w, h);
        ctx.drawImage(img, x, y, w, h, 0, 0, w, h);

        const tileBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
        const tileBuf = await readFileAsArrayBuffer(tileBlob);

        tiles.push({ x, y, width: w, height: h, bytes: tileBuf });
      }
    }

    URL.revokeObjectURL(url);
    return { fullWidth, fullHeight, tiles };
  }

  async function getSnapshotJson() {
    const file = $('jsonFile').files && $('jsonFile').files[0];
    const pasted = $('jsonText').value.trim();

    if (pasted) return JSON.parse(pasted);
    if (file) return JSON.parse(await readFileAsText(file));
    return null;
  }

  $('import').onclick = async () => {
    try {
      setStatus('Reading inputs...');

      const pngFile = $('png').files && $('png').files[0];
      const snapshot = await getSnapshotJson();

      const shotType = $('shotType').value;
      const scaleMode = $('scaleMode').value;
      const lockBg = $('lockBg').checked;
      const importRects = $('importRects').checked;
      const importText = $('importText').checked;
      const groupLayers = $('groupLayers').checked;

      let screenshot = null;
      if (pngFile) {
        setStatus('Processing screenshot (tiling if needed)...');
        const { fullWidth, fullHeight, tiles } = await getPngInfoAndTiles(pngFile);
        screenshot = { name: pngFile.name, fullWidth, fullHeight, tiles };
      }

      setStatus('Sending to plugin...');
      parent.postMessage({
        pluginMessage: {
          type: 'import',
          screenshot,
          snapshot,
          options: { shotType, scaleMode, lockBg, importRects, importText, groupLayers }
        }
      }, '*');
    } catch (e) {
      setStatus('Error: ' + (e && e.message ? e.message : String(e)));
    }
  };

  $('cancel').onclick = () => {
    parent.postMessage({ pluginMessage: { type: 'close' } }, '*');
  };
</script>
</body>
</html>
`;

figma.showUI(UI_HTML, { width: 440, height: 600 });

const fontLoadCache = new Map();

function clamp01(n) {
  return Math.max(0, Math.min(1, n));
}

function parsePx(v) {
  if (v == null) return null;
  const n = parseFloat(String(v));
  return Number.isFinite(n) ? n : null;
}

function parseCssColor(str) {
  const s = String(str || '').trim().toLowerCase();
  if (!s || s === 'transparent') return { color: { r: 0, g: 0, b: 0 }, opacity: 0 };

  if (s.startsWith('#')) {
    let hex = s.slice(1);
    if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
    if (hex.length === 6 || hex.length === 8) {
      const r = parseInt(hex.slice(0, 2), 16) / 255;
      const g = parseInt(hex.slice(2, 4), 16) / 255;
      const b = parseInt(hex.slice(4, 6), 16) / 255;
      const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
      return { color: { r, g, b }, opacity: a };
    }
  }

  const m = s.match(/^rgba?\((.+)\)$/);
  if (m) {
    const parts = m[1].split(',').map(p => p.trim());
    const r = clamp01(parseFloat(parts[0]) / 255);
    const g = clamp01(parseFloat(parts[1]) / 255);
    const b = clamp01(parseFloat(parts[2]) / 255);
    const a = parts.length >= 4 ? clamp01(parseFloat(parts[3])) : 1;
    return { color: { r, g, b }, opacity: a };
  }

  return { color: { r: 0, g: 0, b: 0 }, opacity: 1 };
}

function cssTextAlignToFigma(v) {
  const s = String(v || '').toLowerCase();
  if (s === 'center') return 'CENTER';
  if (s === 'right' || s === 'end') return 'RIGHT';
  if (s === 'justify') return 'JUSTIFIED';
  return 'LEFT';
}

function cssTransformToTextCase(v) {
  const s = String(v || '').toLowerCase();
  if (s === 'uppercase') return 'UPPER';
  if (s === 'lowercase') return 'LOWER';
  if (s === 'capitalize') return 'TITLE';
  return 'ORIGINAL';
}

function cssDecorationToFigma(v) {
  const s = String(v || '').toLowerCase();
  if (s.includes('underline')) return 'UNDERLINE';
  if (s.includes('line-through')) return 'STRIKETHROUGH';
  return 'NONE';
}

function mapWeightToStyle(weightStr, italic) {
  const w = parseInt(weightStr, 10);
  let base = 'Regular';
  if (Number.isFinite(w)) {
    if (w >= 800) base = 'Extra Bold';
    else if (w >= 700) base = 'Bold';
    else if (w >= 600) base = 'Semi Bold';
    else if (w >= 500) base = 'Medium';
    else base = 'Regular';
  }
  return italic ? base + ' Italic' : base;
}

async function loadFontSafe(family, style) {
  const key = `${family}::${style}`;
  if (fontLoadCache.has(key)) return fontLoadCache.get(key);

  const p = (async () => {
    try {
      await figma.loadFontAsync({ family, style });
      return { family, style };
    } catch (e) {
      await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
      return { family: 'Inter', style: 'Regular' };
    }
  })();

  fontLoadCache.set(key, p);
  return p;
}

function computeScale(options, screenshot, snapshot) {
  const mode = options.scaleMode || 'auto';
  if (mode !== 'auto') {
    const n = parseFloat(mode);
    return { scaleX: n, scaleY: n };
  }

  if (!screenshot || !snapshot) return { scaleX: 1, scaleY: 1 };

  const shotType = options.shotType || 'viewport';
  const expectedW = shotType === 'full'
    ? snapshot.meta?.page?.fullWidth
    : snapshot.meta?.viewport?.width;
  const expectedH = shotType === 'full'
    ? snapshot.meta?.page?.fullHeight
    : snapshot.meta?.viewport?.height;

  if (!expectedW || !expectedH) return { scaleX: 1, scaleY: 1 };

  return {
    scaleX: screenshot.fullWidth / expectedW,
    scaleY: screenshot.fullHeight / expectedH,
  };
}

figma.ui.onmessage = async (msg) => {
  if (msg.type === 'close') return figma.closePlugin();
  if (msg.type !== 'import') return;

  const screenshot = msg.screenshot || null;
  const snapshot = msg.snapshot || null;
  const options = msg.options || {};

  const shotType = options.shotType || 'viewport';
  const lockBg = options.lockBg !== false;
  const importRects = options.importRects !== false;
  const importText = options.importText !== false;
  const groupLayers = options.groupLayers !== false;

  const frameW = screenshot
    ? screenshot.fullWidth
    : (shotType === 'full' ? snapshot?.meta?.page?.fullWidth : snapshot?.meta?.viewport?.width) || 1440;

  const frameH = screenshot
    ? screenshot.fullHeight
    : (shotType === 'full' ? snapshot?.meta?.page?.fullHeight : snapshot?.meta?.viewport?.height) || 900;

  const { scaleX, scaleY } = computeScale(options, screenshot, snapshot);
  const scaleS = Math.max(scaleX, scaleY);

  const originX = (shotType === 'viewport') ? (snapshot?.meta?.page?.scrollX || 0) : 0;
  const originY = (shotType === 'viewport') ? (snapshot?.meta?.page?.scrollY || 0) : 0;

  const frame = figma.createFrame();
  frame.name = `Web Snapshot${snapshot?.meta?.title ? ` — ${snapshot.meta.title}` : ''}`;
  frame.resize(Math.round(frameW), Math.round(frameH));
  frame.clipsContent = false;

  const c = figma.viewport.center;
  frame.x = Math.round(c.x - frame.width / 2);
  frame.y = Math.round(c.y - frame.height / 2);

  // Background screenshot tiles (locked)
  if (screenshot) {
    for (const tile of screenshot.tiles) {
      const bytes = new Uint8Array(tile.bytes);
      const image = figma.createImage(bytes);

      const rect = figma.createRectangle();
      rect.name = `Screenshot Tile (${tile.x},${tile.y})`;
      rect.x = tile.x;
      rect.y = tile.y;
      rect.resize(tile.width, tile.height);
      rect.fills = [{ type: 'IMAGE', scaleMode: 'FILL', imageHash: image.hash }];

      if (lockBg) rect.locked = true;
      frame.appendChild(rect);
    }
  } else {
    frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
  }

  const importedNodes = [];
  const layers = Array.isArray(snapshot?.layers) ? snapshot.layers : [];

  for (const layer of layers) {
    if (layer.type === 'rect' && !importRects) continue;
    if (layer.type === 'text' && !importText) continue;

    const x = (layer.x - originX) * scaleX;
    const y = (layer.y - originY) * scaleY;
    const w = Math.max(1, layer.width * scaleX);
    const h = Math.max(1, layer.height * scaleY);

    const nodeOpacity = clamp01(parseFloat(layer.opacity ?? '1') || 1);

    if (layer.type === 'rect') {
      const r = figma.createRectangle();
      r.name = layer.name || 'rect';
      r.x = x; r.y = y;
      r.resize(w, h);
      r.opacity = nodeOpacity;

      // Corner radii
      const radii = layer.radii || {};
      const tl = (parseFloat(radii.tl) || 0) * scaleS;
      const tr = (parseFloat(radii.tr) || 0) * scaleS;
      const br = (parseFloat(radii.br) || 0) * scaleS;
      const bl = (parseFloat(radii.bl) || 0) * scaleS;

      if (tl === tr && tr === br && br === bl) {
        r.cornerRadius = tl;
      } else {
        r.topLeftRadius = tl;
        r.topRightRadius = tr;
        r.bottomRightRadius = br;
        r.bottomLeftRadius = bl;
      }

      // Fill
      if (layer.fill) {
        const { color, opacity } = parseCssColor(layer.fill);
        r.fills = [{ type: 'SOLID', color, opacity }];
      } else {
        r.fills = [];
      }

      // Border / Stroke
      const b = layer.border || null;
      if (b) {
        const topW = (parseFloat(b.top?.width) || 0) * scaleS;
        const rightW = (parseFloat(b.right?.width) || 0) * scaleS;
        const bottomW = (parseFloat(b.bottom?.width) || 0) * scaleS;
        const leftW = (parseFloat(b.left?.width) || 0) * scaleS;

        const widths = [topW, rightW, bottomW, leftW];
        const anyWidth = widths.some(v => v > 0.001);

        const styles = [b.top?.style, b.right?.style, b.bottom?.style, b.left?.style]
          .map(v => String(v || '').toLowerCase());
        const anyStyle = styles.some(st => st && st !== 'none' && st !== 'hidden');

        // Figma can’t do per-side stroke colors; only weights can vary.
        // So only apply stroke paint if colors are consistent; otherwise it uses top color.
        const colors = [b.top?.color, b.right?.color, b.bottom?.color, b.left?.color].map(String);
        const allSameColor = colors.every(c => c === colors[0]);
        const strokeColorStr = allSameColor ? colors[0] : colors[0];

        if (anyWidth && anyStyle) {
          const { color, opacity } = parseCssColor(strokeColorStr);
          r.strokes = [{ type: 'SOLID', color, opacity }];

          // CSS borders behave closest to INSIDE strokes for a captured bounding box
          r.strokeAlign = 'INSIDE';

          if (topW === rightW && rightW === bottomW && bottomW === leftW) {
            r.strokeWeight = topW;
          } else {
            r.strokeTopWeight = topW;
            r.strokeRightWeight = rightW;
            r.strokeBottomWeight = bottomW;
            r.strokeLeftWeight = leftW;
          }
        } else {
          r.strokes = [];
        }
      }

      // Shadows
      const shadows = Array.isArray(layer.shadows) ? layer.shadows : [];
      if (shadows.length) {
        const effects = [];
        for (const sh of shadows) {
          const { color, opacity } = parseCssColor(sh.color);
          const inset = !!sh.inset;

          const offsetX = (parseFloat(sh.offsetX) || 0) * scaleX;
          const offsetY = (parseFloat(sh.offsetY) || 0) * scaleY;
          const radius = (parseFloat(sh.blur) || 0) * scaleS;
          const spread = (parseFloat(sh.spread) || 0) * scaleS;

          const eff = {
            type: inset ? 'INNER_SHADOW' : 'DROP_SHADOW',
            color: { ...color, a: opacity },
            offset: { x: offsetX, y: offsetY },
            radius,
            visible: true,
            blendMode: 'NORMAL',
          };

          if (Math.abs(spread) > 0.001) eff.spread = spread;
          effects.push(eff);
        }
        r.effects = effects;
      } else {
        r.effects = [];
      }

      frame.appendChild(r);
      importedNodes.push(r);
      continue;
    }

    if (layer.type === 'text') {
      const st = layer.style || {};
      const family = st.fontFamily || 'Inter';
      const italic = String(st.fontStyle || '').toLowerCase().includes('italic');
      const style = mapWeightToStyle(st.fontWeight, italic);

      const fontName = await loadFontSafe(family, style);

      const t = figma.createText();
      t.name = layer.name || 'text';
      t.x = x; t.y = y;

      t.fontName = fontName;
      t.characters = layer.characters || '';

      const fontSize = parsePx(st.fontSize) || 14;
      t.fontSize = fontSize * scaleS;

      const lh = parsePx(st.lineHeight);
      if (lh != null) t.lineHeight = { unit: 'PIXELS', value: lh * scaleS };

      const ls = parsePx(st.letterSpacing);
      if (ls != null) t.letterSpacing = { unit: 'PIXELS', value: ls * scaleS };

      t.textAlignHorizontal = cssTextAlignToFigma(st.textAlign);
      t.textCase = cssTransformToTextCase(st.textTransform);
      t.textDecoration = cssDecorationToFigma(st.textDecorationLine);

      const { color, opacity } = parseCssColor(layer.color);
      t.fills = [{ type: 'SOLID', color, opacity }];

      t.opacity = nodeOpacity;

      t.textAutoResize = 'NONE';
      t.resize(Math.max(1, w), Math.max(1, h));

      frame.appendChild(t);
      importedNodes.push(t);
      continue;
    }
  }

  figma.currentPage.appendChild(frame);

  if (groupLayers && importedNodes.length) {
    figma.group(importedNodes, frame);
  }

  figma.currentPage.selection = [frame];
  figma.viewport.scrollAndZoomIntoView([frame]);

  figma.closePlugin('Imported web snapshot.');
};

4.3 Run the plugin

In Figma:

  1. Plugins → Development → Web Snapshot Import

  2. Upload the PNG screenshot

  3. Upload the snapshot JSON (or paste the JSON)

  4. Choose Screenshot type:

    • Viewport if you used “Capture screenshot”
    • Full page if you used “Capture full size screenshot”
  5. Click Import


Tuning and operational guidance

If you get too many rectangle layers

Increase MIN_RECT_AREA in the console script:

  • 25 → captures many small details
  • 100 → ignores anything smaller than ~10×10
  • 400 → ignores anything smaller than ~20×20
  • 900 → ignores anything smaller than ~30×30

If import feels slow or the JSON is huge

  • Increase MIN_RECT_AREA
  • Reduce MAX_LAYERS
  • Consider disabling INCLUDE_TEXT or INCLUDE_RECTS temporarily to isolate performance issues

Layer ordering

Layers are imported in DOM traversal order (based on querySelectorAll('*') index), then sorted by order. This is a reasonable approximation but not identical to true paint order in all cases (especially with complex stacking contexts).


Known limitations (expected)

Visual effects not reconstructed (or partially reconstructed)

  • Gradients and background images are not exported as editable paints (screenshot remains the ground truth).
  • CSS filter, backdrop-filter, masks, clips, blend modes are not reconstructed.

SVG/canvas/pseudo-elements

  • Text rendered by <canvas> is not captured as text.
  • SVG icons are not imported as vectors (they remain in the screenshot).
  • ::before / ::after generated content is not included unless it appears as actual DOM text (it usually does not).

Borders

  • Figma supports per-side stroke weights, but not per-side stroke colors. If border colors differ by side, the plugin uses the top border color.

Fonts

  • Figma requires fonts to be available/loaded. The plugin attempts to load the captured font; if it fails, it falls back to Inter Regular.

Data handling notes

  • Prefer capturing from staging with anonymized data.
  • Avoid capturing sensitive PII in screenshots or text layers if the resulting Figma file will be broadly shared.
  • If your app displays secrets/tokens in the UI, do not capture those screens.

Next increment (if you need even more editability)

The next meaningful automation steps after “rectangles + text” are:

  1. Images: export <img> elements and (if feasible) CSS background images as image fills.
  2. SVG vectors: export inline SVGs to vectors (more complex).
  3. True paint order: compute paint order using stacking contexts (complex, but possible).

If you pursue images next, the main constraint is whether images are same-origin and can be read/exported without violating CORS restrictions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment