DEV Community

Cover image for I Built a Figma Plugin to Kill Translation Bugs Before They Reach Developers — Here's Every Wall I Hit
Aryan Saxena
Aryan Saxena

Posted on

I Built a Figma Plugin to Kill Translation Bugs Before They Reach Developers — Here's Every Wall I Hit

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:

  1. Open the plugin inside any Figma file
  2. Select a frame (or an entire page)
  3. Pick target languages — German, Arabic, Japanese, Hindi, whatever you need
  4. 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)              │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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" })
});
Enter fullscreen mode Exit fullscreen mode

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"
});
Enter fullscreen mode Exit fullscreen mode

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"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(() => {})
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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


Built with Lingo.dev, React, TypeScript, and a mass of patience for Figma's sandbox.

Top comments (0)