The Moment That Started This
Picture this: You are reviewing a pull request. The developer built the UI exactly as designed. Every pixel matches the Figma mockup. It looks perfect.
Then QA opens it in German.
The "Jetzt kaufen" button text overflows its container. The navigation bar wraps to two lines. The hero section headline is clipped mid-sentence. A modal dialog has text bleeding into the close button.
A bug ticket gets filed. Against the developer.
But the developer did nothing wrong. They built exactly what the designer handed them. The designer just never checked what happens when "Buy Now" becomes "Jetzt kaufen" (40% longer), or when "Settings" becomes "Einstellungen" (130% longer).
This is not a code bug. It is a planning gap.
I kept running into this pattern across projects, and I kept thinking the same thing: why are we discovering these problems after development, when they should be caught during design?
So I built LingoAudit — a Figma plugin that translates your designs into multiple languages, generates localized copies of your screens, and highlights every text box that will break. All inside Figma, before a single line of code is written.
It took me down a rabbit hole of sandbox restrictions, CORS nightmares, and typography destruction that I never expected. This is the full story.
What LingoAudit Actually Does
Before diving into the technical chaos, here is what the finished product looks like:
- Open the plugin inside any Figma file
- Select a frame (or an entire page)
- Pick target languages — German, Arabic, Japanese, Hindi, whatever you need
- Click Scan
The plugin then:
- Extracts every text node from the selected frames
- Sends them to the Lingo.dev translation engine in batched chunks
- Clones your entire frame for each target language
- Injects the translated text into the clones while preserving all formatting
- Measures every text box against its container
- Highlights overflows with severity indicators (Critical / Warning / Safe)
Your original design is never touched. You get side-by-side translated copies showing exactly where your layout breaks.
The results panel categorizes everything so the designer can fix layouts immediately — before any developer writes any code.
The Tech Stack
Here is what powers LingoAudit under the hood:
| Layer | Technology | Role |
|---|---|---|
| Plugin Sandbox | TypeScript | Reads/writes the Figma document, clones frames, measures text |
| Plugin UI | React + TypeScript | API key management, locale selection, results display |
| Translation | Lingo.dev SDK | AI-powered localization engine |
| Bundler | Webpack | Dual-target build (sandbox + UI iframe) |
| Styling | Vanilla CSS | Figma-native dark theme |
The critical thing to understand about Figma plugins is that they run in two completely separate contexts:
┌─────────────────────────────────────────────────────────┐
│ SANDBOX (code.ts) │
│ - Can read/write Figma layers │
│ - Has NO fetch, NO DOM, NO window │
│ - Runs in a QuickJS-like restricted realm │
│ │
│ ↕ postMessage (async, serialized) │
│ │
│ UI IFRAME (index.tsx) │
│ - Has fetch, DOM, React │
│ - CANNOT access Figma layers │
│ - Subject to browser CORS policies │
│ - Origin is "null" (not http://localhost) │
└─────────────────────────────────────────────────────────┘
Every single interaction between these two halves happens through serialized postMessage calls. There is no shared memory, no shared state, no function calls across the boundary.
This architecture is the source of almost every challenge I faced.
Wall 1: The Network Is Unreachable
My first approach was obvious: call the Lingo.dev API from the UI iframe using fetch.
// This seems reasonable, right?
const response = await fetch("https://engine.lingo.dev/translate", {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ texts, targetLocale: "de" })
});
It failed instantly. The browser blocked it.
Here is why: Figma plugin iframes have a null origin. When you send a request with custom headers (like Authorization) from a null origin, the browser fires a CORS preflight OPTIONS request. The target server needs to respond with Access-Control-Allow-Origin: * (or specifically allow null). Most APIs do not do this — and they should not, because allowing null origins is a security risk.
What I tried:
| Attempt | Result |
|---|---|
Direct fetch to engine.lingo.dev
|
CORS preflight blocked |
Routing through the Figma sandbox's fetch
|
Sandbox has no fetch API |
corsproxy.io |
403 Forbidden |
thingproxy.freeboard.io |
DNS resolution failure |
proxy.cors.sh |
Required paid API key |
api.allorigins.win |
Mangled POST body |
Five proxies. Five failures. Each one for a different reason.
What actually worked:
The sixth proxy, cors.eu.org, properly handled both the OPTIONS preflight and the POST body passthrough. I configured the Lingo.dev SDK to route through it:
const lingoDotDev = new LingoDotDevEngine({
apiKey,
apiUrl: "https://cors.eu.org/https://engine.lingo.dev"
});
And added it to Figma's allowed domains in manifest.json:
{
"networkAccess": {
"allowedDomains": [
"https://cors.eu.org",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}
}
The lesson: If you are building a Figma plugin that calls any third-party API with auth headers, you will need a proxy. Plan for it from day one. Ideally, deploy your own (a 15-line Cloudflare Worker would do it). A public proxy works for a prototype but is not suitable for production since all traffic passes through a server you do not control.
Wall 2: Replacing Text Destroys Typography
This one was painful to discover. Here is a simplified version of what I expected to work:
const clone = textNode.clone();
await figma.loadFontAsync(clone.fontName as FontName);
clone.characters = "Jetzt kaufen"; // German translation
It works perfectly — until you hit a text node with mixed formatting.
In Figma, a single text node can contain bold, italic, different sizes, and different colors all within the same string. Think of a heading like "Welcome to LingoAudit" where "LingoAudit" is bold and green.
When you read node.fontName on such a node, Figma does not return a font. It returns a special symbol: figma.mixed. And if you try to set node.characters without first setting a uniform fontName, Figma throws an error.
The obvious fix — force the entire node to one font, then replace text — works but annihilates every bit of styling the designer applied. The bold disappears. The colors reset. The design looks broken.
My solution: Character-Level Style Mapping
Before replacing any text, I extract the formatting of every single character:
const styles: StyleRecord[] = [];
for (let i = 0; i < originalLen; i++) {
styles.push({
fontName: node.getRangeFontName(i, i + 1),
fontSize: node.getRangeFontSize(i, i + 1),
fills: node.getRangeFills(i, i + 1),
});
}
After injecting the translated text, I proportionally map those styles onto the new string:
for (let i = 0; i <= newLen; i++) {
// Map position in new string to position in original string
const originalIndex = Math.min(
Math.floor((i / newLen) * originalLen),
originalLen - 1
);
// Apply the style from that original position
node.setRangeFontName(rangeStart, i, styles[originalIndex].fontName);
node.setRangeFontSize(rangeStart, i, styles[originalIndex].fontSize);
node.setRangeFills(rangeStart, i, styles[originalIndex].fills);
}
Is the mapping perfect? Not mathematically. If the translated text is dramatically shorter or longer, the style boundaries stretch or compress. But in practice, headings stay bold, colored words stay colored, and the design looks intentional rather than broken.
Wall 3: Font Loading Freezes Everything
Figma enforces a strict rule: before you can modify any text on a node, you must call await figma.loadFontAsync(fontName) for the exact font that node uses.
My initial implementation loaded fonts inside the per-node loop:
// Slow: loads fonts redundantly for every node
for (const textNode of allTextNodes) {
await figma.loadFontAsync(textNode.fontName as FontName); // blocking
textNode.characters = translatedText;
}
On a frame with 300 text nodes using 8 different fonts, this makes up to 300 async calls — most of them redundant duplicates loading the same font over and over. Figma's UI completely locks up for 5-15 seconds.
The fix — batch parallel loading:
async function prepareFontsForNodes(nodes: TextNode[]): Promise<void> {
const uniqueFonts = new Set<string>();
for (const node of nodes) {
if (node.fontName === figma.mixed) {
// Extract every unique font from mixed-style nodes
for (let i = 0; i < node.characters.length; i++) {
uniqueFonts.add(JSON.stringify(node.getRangeFontName(i, i + 1)));
}
} else {
uniqueFonts.add(JSON.stringify(node.fontName));
}
}
// Load ALL unique fonts concurrently
await Promise.all(
Array.from(uniqueFonts).map(f =>
figma.loadFontAsync(JSON.parse(f) as FontName).catch(() => {})
)
);
}
One pass to collect. One Promise.all to load. The per-node loop then runs without any font loading at all. On a 300-node frame, the difference is the gap between a 12-second freeze and near-instant execution.
The .catch(() => {}) is deliberate — if a designer uses a local font that is not installed on the current machine, the load will fail. Instead of crashing the entire plugin, we catch it silently and fall back to Inter when we actually set the characters. The user still gets their translated screen; the font just defaults gracefully.
Wall 4: The Overflow Measurement Was Lying
Figma text nodes have a textAutoResize property that dictates how the bounding box behaves:
| Mode | Behavior |
|---|---|
WIDTH_AND_HEIGHT |
Box grows in both directions to fit |
HEIGHT |
Box keeps its width, grows downward |
NONE |
Box is completely fixed, text clips |
For overflow detection, I clone the node, inject the translated text, and compare the resulting dimensions to the original. My initial approach for fixed (NONE) boxes was to temporarily set the clone to WIDTH_AND_HEIGHT to see how wide the text "wants" to be.
The problem: This unwraps multi-line text into a single horizontal line. A paragraph that fits perfectly when wrapped was being reported as 200% overflow because it was measured as one infinitely wide line.
// Wrong: measures single-line width, not real overflow
clone.textAutoResize = "WIDTH_AND_HEIGHT";
overflowAmount = clone.width - originalWidth; // Wildly inaccurate
The fix: Lock the clone's width to the original, set it to HEIGHT mode, and measure vertical growth:
// Correct: keeps width locked, measures downward expansion
clone.resize(originalWidth, originalHeight);
clone.textAutoResize = "HEIGHT";
overflowAmount = clone.height - originalHeight; // Accurate
If the clone grows taller than the original fixed box, that is your real overflow — the text needs more vertical space than the designer allocated.
Wall 5: Large Files Crash the API
A production design file can have thousands of text nodes. Sending all of them to a translation API in a single HTTP request results in either a 413 Payload Too Large response or a timeout.
The fix — automatic chunking with progress tracking:
const CHUNK_SIZE = 50;
for (let i = 0; i < texts.length; i += CHUNK_SIZE) {
const chunk = texts.slice(i, i + CHUNK_SIZE);
const content: Record<string, string> = {};
chunk.forEach((text, idx) => { content[`str_${idx}`] = text; });
const translated = await lingoDotDev.localizeObject(content, {
sourceLocale: source,
targetLocale: target,
});
// Map results back to the master array
chunk.forEach((text, idx) => {
results[i + idx] = translated[`str_${idx}`] ?? text;
});
// Report progress to UI
onProgress(Math.min(((i + chunk.length) / texts.length) * 100, 100));
}
Each completed chunk fires a progress callback. The React UI uses this to drive a smooth progress bar. Instead of staring at a frozen screen for 30 seconds, the user sees incremental progress across both chunks and locales.
The Non-Destructive Cloning Architecture
Early versions of the plugin modified the original design — adding red stroke borders around overflowing text. Users hated it. Nobody wants a tool that alters their source of truth.
The final architecture clones the entire selected frame for each locale:
for (const locale of Object.keys(byLocale)) {
for (const root of rootNodes) {
const clone = root.clone();
clone.x += xOffset; // Place next to original
clone.name = `${root.name} - ${locale.toUpperCase()}`;
// Walk both trees in parallel
const origTexts = walkTextNodes(root);
const cloneTexts = walkTextNodes(clone);
for (let i = 0; i < origTexts.length; i++) {
const result = resultMap.get(origTexts[i].id);
if (result) {
applyTextWithMixedStyles(cloneTexts[i], result.translatedText);
if (result.isOverflow) highlightNode(cloneTexts[i]);
}
}
}
xOffset += totalRootWidth;
}
The key insight is walking the original tree and the cloned tree simultaneously. Since clone() preserves the tree structure, the i-th text node in the clone corresponds to the i-th text node in the original. We use the original node's ID to look up translation results, then apply them to the clone.
The result: the designer's original frame is untouched. The translated copies appear neatly to the right, with overflow highlights only on the clones.
What I Would Do Differently
Deploy my own proxy from day one. I wasted hours testing five different public CORS proxies. A 15-line Cloudflare Worker would have solved it permanently in 10 minutes.
Cache translations locally. Right now, scanning the same frame twice re-translates everything. A Map<string, string> keyed by hash(locale + sourceText) stored in figma.clientStorage would eliminate redundant API calls and make re-scans nearly instant.
Handle component instances smarter. Figma components and instances have their own text override system. My walker treats them as regular nodes, which works, but a smarter implementation could translate at the component master level and propagate to all instances automatically.
The Impact
The core thesis held up: translation overflow is a design problem that should be caught during design.
With LingoAudit, a designer can test 10 languages on a complex screen in under 30 seconds. Without it, the same check requires manually copy-pasting translations, creating duplicate frames by hand, and visually inspecting each one. That workflow takes hours per screen and is so tedious that nobody actually does it — which is exactly why these bugs survive into production.
The plugin handles:
- Mixed-style typography preservation across translations
- Right-to-left languages (Arabic, Hebrew) with automatic text direction
- Large files with thousands of text nodes via chunked batching
- Missing fonts with graceful fallback instead of crashes
- Non-destructive workflow that never touches original designs
Try It Yourself
The project is open source: github.com/AryanSaxenaa/Figma-Plugin-Lingo
To run it locally:
git clone https://github.com/AryanSaxenaa/Figma-Plugin-Lingo.git
cd Figma-Plugin-Lingo
npm install
npm run build
Then in Figma: Plugins > Development > Import plugin from manifest and select the manifest.json.
You will need a Lingo.dev API key to use the translation features.
Resources
- Figma Plugin API Documentation
- Lingo.dev SDK
- i18next — If you need runtime translation in React apps
- Unicode CLDR — The standard for locale data
Built with Lingo.dev, React, TypeScript, and a mass of patience for Figma's sandbox.




Top comments (0)