Screenshot background + editable rectangles (fills/borders/shadows) + editable text
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.”
-
Google Chrome (or Chromium)
-
Figma Desktop (recommended for local plugin development)
-
Permission to run:
- JavaScript in the Chrome DevTools Console
- Figma “Development” plugins
-
Stabilize the page (optional): disable animations/transitions for a clean capture.
-
Capture screenshot: viewport or full-page PNG.
-
Export snapshot JSON: run a console script to extract rectangles + text from the DOM.
-
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
-
Tune filters if too many rectangles are generated.
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.');
})();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.
Run the following in Chrome DevTools Console on the page at the time of capture.
Key settings at the top:
MIN_RECT_AREA: increase to reduce the number of rectangle layersMAX_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)
In Figma Desktop:
- Plugins → Development → New Plugin…
- Choose Create new plugin
- Choose any name, e.g. Web Snapshot Import
- Choose With UI (recommended)
- Open the created plugin folder and replace
code.jswith the file below.
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.');
};In Figma:
-
Plugins → Development → Web Snapshot Import
-
Upload the PNG screenshot
-
Upload the snapshot JSON (or paste the JSON)
-
Choose Screenshot type:
- Viewport if you used “Capture screenshot”
- Full page if you used “Capture full size screenshot”
-
Click Import
Increase MIN_RECT_AREA in the console script:
25→ captures many small details100→ ignores anything smaller than ~10×10400→ ignores anything smaller than ~20×20900→ ignores anything smaller than ~30×30
- Increase
MIN_RECT_AREA - Reduce
MAX_LAYERS - Consider disabling
INCLUDE_TEXTorINCLUDE_RECTStemporarily to isolate performance issues
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).
- 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.
- Text rendered by
<canvas>is not captured as text. - SVG icons are not imported as vectors (they remain in the screenshot).
::before/::aftergenerated content is not included unless it appears as actual DOM text (it usually does not).
- 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.
- Figma requires fonts to be available/loaded. The plugin attempts to load the captured font; if it fails, it falls back to Inter Regular.
- 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.
The next meaningful automation steps after “rectangles + text” are:
- Images: export
<img>elements and (if feasible) CSS background images as image fills. - SVG vectors: export inline SVGs to vectors (more complex).
- 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.