Skip to content

Instantly share code, notes, and snippets.

@steveruizok
Created June 27, 2026 14:35
Show Gist options
  • Select an option

  • Save steveruizok/e535d4443304835d03bd04b0eea519b0 to your computer and use it in GitHub Desktop.

Select an option

Save steveruizok/e535d4443304835d03bd04b0eea519b0 to your computer and use it in GitHub Desktop.
Figma: localize styles and variables

Localize Figma Styles & Variables

A Scriptr / Figma Plugin API script that breaks a file's dependency on an external library by converting every remote style and variable in use into a local copy, then repointing all layers at those local copies.

Useful when you've inherited (or want to fork) a file whose components reference styles/variables published from a different Figma file, and you want it to stand on its own.

By @steveruizok

What it does

  1. Scans every page for styles and variables in use that are remote (linked to another file).
  2. Duplicates them locally — paint, text, effect, and grid styles, plus variables (with their collections, modes, scopes, and cross-variable aliases preserved).
  3. Rebinds every layer to the new local styles/variables: style IDs, node-level variable bindings (width, padding, radius, font size, …), and variables bound inside fills/strokes/effects/grids.

After it runs, the file no longer depends on the source library.

Usage

  1. Open your file and create a branch or duplicate it first — this rewrites bindings document-wide.
  2. Open Scriptr (or any plugin that runs Plugin API code).
  3. Paste in figma-localize-styles.js.
  4. Leave DRY_RUN = true and run once. It reports what would change without touching anything:
    [DRY RUN]
    Styles — create 12, update 0
    Variables — create 5, update 0
    
  5. If the counts look right, set DRY_RUN = false and run again to apply. It reports:
    Styles — created 12, updated 0
    Variables — created 5, updated 0 (collections: +2/~0)
    Rebinds — styles 248, variables 96
    
    The Rebinds numbers confirm layers were actually repointed.
  6. Spot-check a component: select a layer — its style/variable should now show as local (no library icon).

Idempotent re-runs

Each local copy is tagged (via shared plugin data) with the key of its remote source. On a second run the script matches and updates those existing copies instead of creating duplicates — so it's safe to re-run to catch newly added remote usage or recover from a partial run.

Note: copies made before this tagging existed (or by other means) won't be recognized and may be duplicated once.

Scope & behavior

  • Whole document, all pages.
  • Instance interiors are skipped — only main components and other direct style users are rewritten; instances inherit the new local styles through their main component automatically.

Known gaps

These are localized as variables but not re-bound at these specific spots (rare; open an issue / tweak the script if you need them):

  • Variables bound to individual gradient stops
  • Variables bound to component properties
  • Per-text-range variable bindings (uniform node-level text bindings like font size are handled)

Caveats

  • Always branch/duplicate first. There's no undo beyond Figma's normal history.
  • Once localized, the link to the source library is gone — these styles/variables won't auto-update from it anymore (that's the point).
  • This handles styles and variables, not component definitions themselves.

License

MIT — do whatever you like.

(async () => {
// ─────────────────────────────────────────────────────────────
const DRY_RUN = false; // true = report only. Flip to false to apply.
const NS = 'localizer'; // plugin-data namespace used to recognize our own copies
// ─────────────────────────────────────────────────────────────
await figma.loadAllPagesAsync();
const COMPLEX_BV_KEYS = new Set(['fills', 'strokes', 'effects', 'layoutGrids', 'componentProperties', 'textRangeFills']);
const remoteToLocalStyle = new Map(); // remote style id -> local style id
const localVarMap = new Map(); // original var id -> Variable to bind
const directVarIds = new Set();
const styleIds = new Set();
let styleCreated = 0;
let styleUpdated = 0;
let varCreated = 0;
let varUpdated = 0;
let collCreated = 0;
let collUpdated = 0;
let styleRebinds = 0;
let varRebinds = 0;
const collectAlias = (a) => { if (a && a.type === 'VARIABLE_ALIAS' && a.id) directVarIds.add(a.id); };
const getSrc = (o) => { try { return o.getSharedPluginData(NS, 'src') || null; } catch (e) { return null; } };
const setSrc = (o, k) => { try { o.setSharedPluginData(NS, 'src', k); } catch (e) {} };
async function loadNodeFonts(node) {
if (!node.characters || node.characters.length === 0) return;
const seen = new Set();
for (const s of node.getStyledTextSegments(['fontName'])) {
const k = `${s.fontName.family}__${s.fontName.style}`;
if (!seen.has(k)) { seen.add(k); try { await figma.loadFontAsync(s.fontName); } catch (e) {} }
}
}
async function traverse(cb) {
for (const page of figma.root.children) {
const stack = [...page.children];
while (stack.length) {
const n = stack.pop();
await cb(n);
if (n.type !== 'INSTANCE' && 'children' in n) for (const c of n.children) stack.push(c);
}
}
}
// ── Index existing local copies we created previously, keyed by source key ──
const localStyles = [
...await figma.getLocalPaintStylesAsync(),
...await figma.getLocalTextStylesAsync(),
...await figma.getLocalEffectStylesAsync(),
...await figma.getLocalGridStylesAsync(),
];
const existingStyleBySrc = new Map();
for (const s of localStyles) { const src = getSrc(s); if (src) existingStyleBySrc.set(src, s); }
const existingCollBySrc = new Map();
for (const c of await figma.variables.getLocalVariableCollectionsAsync()) { const src = getSrc(c); if (src) existingCollBySrc.set(src, c); }
const existingVarBySrc = new Map();
for (const v of await figma.variables.getLocalVariablesAsync()) { const src = getSrc(v); if (src) existingVarBySrc.set(src, v); }
// ── PHASE A: collect style ids + variable alias ids in use ──────────────
await traverse(async (n) => {
for (const prop of ['fillStyleId', 'strokeStyleId', 'effectStyleId', 'gridStyleId'])
if (prop in n) { const id = n[prop]; if (id && id !== figma.mixed) styleIds.add(id); }
if (n.type === 'TEXT') {
if (n.textStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['textStyleId'])) if (s.textStyleId) styleIds.add(s.textStyleId); }
else if (n.textStyleId) styleIds.add(n.textStyleId);
if (n.fillStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['fillStyleId'])) if (s.fillStyleId) styleIds.add(s.fillStyleId); }
}
if (n.boundVariables) for (const k of Object.keys(n.boundVariables)) {
const v = n.boundVariables[k];
if (Array.isArray(v)) v.forEach(collectAlias); else collectAlias(v);
}
for (const arrProp of ['fills', 'strokes']) if (arrProp in n && Array.isArray(n[arrProp])) for (const p of n[arrProp]) {
if (p && p.boundVariables) for (const f of Object.keys(p.boundVariables)) collectAlias(p.boundVariables[f]);
if (p && p.gradientStops) for (const gs of p.gradientStops) if (gs.boundVariables) for (const f of Object.keys(gs.boundVariables)) collectAlias(gs.boundVariables[f]);
}
if ('effects' in n && Array.isArray(n.effects)) for (const e of n.effects) if (e.boundVariables) for (const f of Object.keys(e.boundVariables)) collectAlias(e.boundVariables[f]);
if ('layoutGrids' in n && Array.isArray(n.layoutGrids)) for (const g of n.layoutGrids) if (g.boundVariables) for (const f of Object.keys(g.boundVariables)) collectAlias(g.boundVariables[f]);
});
// variables referenced inside remote styles
for (const id of styleIds) {
const st = await figma.getStyleByIdAsync(id);
if (!st || !st.remote) continue;
const scan = (o) => { if (o && o.boundVariables) for (const f of Object.keys(o.boundVariables)) collectAlias(o.boundVariables[f]); };
if (st.type === 'PAINT') st.paints.forEach(p => { scan(p); (p.gradientStops || []).forEach(scan); });
if (st.type === 'EFFECT') st.effects.forEach(scan);
if (st.type === 'GRID') st.layoutGrids.forEach(scan);
}
// ── Remote-variable dependency closure ──────────────────────────────────
const remoteVarIds = new Set();
const toProcess = [...directVarIds];
while (toProcess.length) {
const id = toProcess.pop();
if (remoteVarIds.has(id) || localVarMap.has(id)) continue;
const v = await figma.variables.getVariableByIdAsync(id);
if (!v) continue;
const coll = await figma.variables.getVariableCollectionByIdAsync(v.variableCollectionId);
if (!coll || !coll.remote) { localVarMap.set(id, v); continue; }
remoteVarIds.add(id);
for (const mId of Object.keys(v.valuesByMode)) { const val = v.valuesByMode[mId]; if (val && val.type === 'VARIABLE_ALIAS') toProcess.push(val.id); }
}
// ── DRY RUN report (create vs update) ───────────────────────────────────
if (DRY_RUN) {
let sNew = 0, sUpd = 0, vNew = 0, vUpd = 0;
for (const id of styleIds) { const st = await figma.getStyleByIdAsync(id); if (st && st.remote) (existingStyleBySrc.has(st.key) ? sUpd++ : sNew++); }
for (const id of remoteVarIds) { const v = await figma.variables.getVariableByIdAsync(id); if (v) (existingVarBySrc.has(v.key) ? vUpd++ : vNew++); }
const msg = `[DRY RUN]\nStyles — create ${sNew}, update ${sUpd}\nVariables — create ${vNew}, update ${vUpd}`;
console.log(msg);
figma.notify(msg, { timeout: 7000 });
return;
}
// ── PHASE B: create/update collections + variables ──────────────────────
const remoteVars = [];
for (const id of remoteVarIds) remoteVars.push(await figma.variables.getVariableByIdAsync(id));
const byColl = new Map();
for (const v of remoteVars) { if (!byColl.has(v.variableCollectionId)) byColl.set(v.variableCollectionId, []); byColl.get(v.variableCollectionId).push(v); }
const collModeMap = new Map();
for (const [collId] of byColl) {
const rc = await figma.variables.getVariableCollectionByIdAsync(collId);
let lc = existingCollBySrc.get(rc.key);
const isNew = !lc;
if (isNew) { lc = figma.variables.createVariableCollection(rc.name); setSrc(lc, rc.key); collCreated++; }
else { if (lc.name !== rc.name) lc.name = rc.name; collUpdated++; }
const modeMap = new Map();
rc.modes.forEach((m, i) => {
const existing = lc.modes.find(x => x.name === m.name);
if (existing) modeMap.set(m.modeId, existing.modeId);
else if (isNew && i === 0) { lc.renameMode(lc.modes[0].modeId, m.name); modeMap.set(m.modeId, lc.modes[0].modeId); }
else modeMap.set(m.modeId, lc.addMode(m.name));
});
collModeMap.set(collId, { lc, modeMap });
}
// pass 1 — create/find variables
for (const v of remoteVars) {
const { lc } = collModeMap.get(v.variableCollectionId);
let nv = existingVarBySrc.get(v.key);
if (!nv) { nv = figma.variables.createVariable(v.name, lc, v.resolvedType); setSrc(nv, v.key); varCreated++; }
else { if (nv.name !== v.name) nv.name = v.name; varUpdated++; }
try { nv.scopes = v.scopes; } catch (e) {}
nv.description = v.description || '';
try { nv.hiddenFromPublishing = v.hiddenFromPublishing; } catch (e) {}
try { for (const plat of Object.keys(v.codeSyntax || {})) nv.setVariableCodeSyntax(plat, v.codeSyntax[plat]); } catch (e) {}
localVarMap.set(v.id, nv);
}
// pass 2 — set values per mode, resolving aliases
for (const v of remoteVars) {
const nv = localVarMap.get(v.id);
const { modeMap } = collModeMap.get(v.variableCollectionId);
for (const mId of Object.keys(v.valuesByMode)) {
let val = v.valuesByMode[mId];
if (val && val.type === 'VARIABLE_ALIAS') { const t = localVarMap.get(val.id); val = t ? { type: 'VARIABLE_ALIAS', id: t.id } : val; }
try { nv.setValueForMode(modeMap.get(mId), val); } catch (e) {}
}
}
// ── style localization (create or update existing) ──────────────────────
function remapBV(obj, setter) {
let o = obj;
if (obj && obj.boundVariables) for (const f of Object.keys(obj.boundVariables)) {
const al = obj.boundVariables[f];
if (al && al.type === 'VARIABLE_ALIAS') { const lv = localVarMap.get(al.id); if (lv) o = setter(o, f, lv); }
}
return o;
}
async function ensureLocalStyle(remoteStyleId) {
if (!remoteStyleId) return null;
if (remoteToLocalStyle.has(remoteStyleId)) return remoteToLocalStyle.get(remoteStyleId);
const style = await figma.getStyleByIdAsync(remoteStyleId);
if (!style) { remoteToLocalStyle.set(remoteStyleId, null); return null; }
if (!style.remote) { remoteToLocalStyle.set(remoteStyleId, remoteStyleId); return remoteStyleId; }
let local = existingStyleBySrc.get(style.key);
const isNew = !local;
if (isNew) {
if (style.type === 'PAINT') local = figma.createPaintStyle();
else if (style.type === 'EFFECT') local = figma.createEffectStyle();
else if (style.type === 'GRID') local = figma.createGridStyle();
else if (style.type === 'TEXT') local = figma.createTextStyle();
else { remoteToLocalStyle.set(remoteStyleId, null); return null; }
setSrc(local, style.key);
styleCreated++;
} else styleUpdated++;
if (style.type === 'PAINT') local.paints = style.paints.map(p => remapBV(p, figma.variables.setBoundVariableForPaint));
if (style.type === 'EFFECT') local.effects = style.effects.map(e => remapBV(e, figma.variables.setBoundVariableForEffect));
if (style.type === 'GRID') local.layoutGrids = style.layoutGrids.map(g => remapBV(g, figma.variables.setBoundVariableForLayoutGrid));
if (style.type === 'TEXT') {
await figma.loadFontAsync(style.fontName);
local.fontName = style.fontName; local.fontSize = style.fontSize;
local.letterSpacing = style.letterSpacing; local.lineHeight = style.lineHeight;
local.paragraphIndent = style.paragraphIndent; local.paragraphSpacing = style.paragraphSpacing;
local.textCase = style.textCase; local.textDecoration = style.textDecoration;
}
local.name = style.name; local.description = style.description || '';
remoteToLocalStyle.set(remoteStyleId, local.id);
return local.id;
}
// ── PHASE D: rebind every node (styles + variables) ─────────────────────
await traverse(async (n) => {
if (n.type === 'TEXT') await loadNodeFonts(n);
for (const [prop, setter] of [['fillStyleId', 'setFillStyleIdAsync'], ['strokeStyleId', 'setStrokeStyleIdAsync'], ['effectStyleId', 'setEffectStyleIdAsync'], ['gridStyleId', 'setGridStyleIdAsync']]) {
if (prop in n) { const id = n[prop]; if (id && id !== figma.mixed) { const l = await ensureLocalStyle(id); if (l && l !== id) { await n[setter](l); styleRebinds++; } } }
}
if (n.type === 'TEXT') {
if (n.textStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['textStyleId'])) { if (!s.textStyleId) continue; const l = await ensureLocalStyle(s.textStyleId); if (l && l !== s.textStyleId) { await n.setRangeTextStyleIdAsync(s.start, s.end, l); styleRebinds++; } } }
else if (n.textStyleId) { const l = await ensureLocalStyle(n.textStyleId); if (l && l !== n.textStyleId) { await n.setTextStyleIdAsync(l); styleRebinds++; } }
if (n.fillStyleId === figma.mixed) { for (const s of n.getStyledTextSegments(['fillStyleId'])) { if (!s.fillStyleId) continue; const l = await ensureLocalStyle(s.fillStyleId); if (l && l !== s.fillStyleId) { await n.setRangeFillStyleIdAsync(s.start, s.end, l); styleRebinds++; } } }
}
if (n.boundVariables) for (const field of Object.keys(n.boundVariables)) {
if (COMPLEX_BV_KEYS.has(field)) continue;
const al = n.boundVariables[field];
if (al && al.type === 'VARIABLE_ALIAS') { const lv = localVarMap.get(al.id); if (lv && lv.id !== al.id) { try { n.setBoundVariable(field, lv); varRebinds++; } catch (e) {} } }
}
const hasStyle = (p) => (p in n) && n[p] && n[p] !== figma.mixed;
const remapArr = (prop, styleProp, setter) => {
if (hasStyle(styleProp)) return;
if (!(prop in n) || !Array.isArray(n[prop])) return;
let changed = false;
const next = n[prop].map(o => remapBV(o, (x, f, lv) => { changed = true; varRebinds++; return setter(x, f, lv); }));
if (changed) n[prop] = next;
};
remapArr('fills', 'fillStyleId', figma.variables.setBoundVariableForPaint);
remapArr('strokes', 'strokeStyleId', figma.variables.setBoundVariableForPaint);
remapArr('effects', 'effectStyleId', figma.variables.setBoundVariableForEffect);
remapArr('layoutGrids', 'gridStyleId', figma.variables.setBoundVariableForLayoutGrid);
});
const msg = `Styles — created ${styleCreated}, updated ${styleUpdated}\n` +
`Variables — created ${varCreated}, updated ${varUpdated} (collections: +${collCreated}/~${collUpdated})\n` +
`Rebinds — styles ${styleRebinds}, variables ${varRebinds}`;
console.log(msg);
figma.notify(`Localized: ${styleCreated + styleUpdated} styles, ${varCreated + varUpdated} variables`, { timeout: 7000 });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment