
Building Reliable AI Figma-to-Code Loops
We connected a coding agent to Figma’s Model Context Protocol (MCP) server, pointed it at a frame, and asked it to implement the screen. A minute later, we had React that rendered and looked like the design. Our first attempts were convincing enough that the gaps were easy to miss.
The generated code used generic React and Tailwind, with none of our design-system components or tokens. It matched the frame, but a future palette or component change would not reach that implementation. A reviewer looking at a screenshot saw nothing wrong.
The agent also had to guess how Figma components and properties corresponded to our React APIs. A size labeled "xl - mobile only" in Figma might mean size="xl" in code. Those relationships depend on the component and need to be recorded explicitly.
Finally, nothing compared the rendered screen with the frame it came from. The agent reported done, and someone had to open the result to decide whether it was.
We needed a workflow that used our existing library, understood its relationship to Figma, and checked the screen it produced.
In this article, we’ll walk through:
- how the agent reads a Figma frame and turns it into a reference the rest of the loop can use
- how it finds the right design-system components and maps Figma properties to React props
- how a pre-write check blocks imports that our design system supersedes
- how the rendered screen is compared with the design, and how the agent decides whether another pass is needed
- how CI catches later regressions, where visual comparisons mislead us, and what a screenshot cannot verify
None of this depends on a particular model or vendor. The pieces are MCP servers, a pre-write check, and a verification tool, so the loop runs with any agent that can call MCP tools; only the hook wiring is agent-specific. We have run it with Claude Code and Cursor, and the examples below come from those runs.
Architecture of our AI Figma-to-code workflow
Our workflow runs against the project’s existing design-system library, including its React components and design tokens. It gives the agent a component catalog, compiled Figma bindings — a generated record of which Figma component and property corresponds to which React component and prop — and rules that enforce library usage. After writing a screen, the agent renders it in a browser and compares a screenshot with a PNG exported from the Figma frame.
If the comparison fails, the agent edits the code and checks again. A screen is accepted when fewer than 2% of its pixels differ from the reference, or when a look at the diff image shows only sub-pixel and antialiasing differences. The loop has a limit of three verification passes. When it reaches that limit without meeting the acceptance criteria, it stops and reports the remaining differences.

The comparison described here checks the browser-rendered screen. The design system also has a separate Storybook visual regression suite in CI. Device interaction tests cover behavior that a browser screenshot cannot establish. That browser-based loop begins with the Figma frame: its data guides the implementation, and its exported image becomes the reference for comparison.
Reading the Figma frame
The agent takes the frame’s node ID from its Figma URL and reads only that frame, keeping the rest of the design file out of its context.
A read-only MCP server handles extraction through two calls: one retrieves the frame’s data, and the other downloads its images. This stage produces three inputs for the rest of the workflow:
- A reference PNG, saved under
public/figma-ref/. Keeping it on disk gives every verification pass the same reference, including after a restart or a handoff to another agent. - The colors and typography used in the frame, including raw hex values and font specifications.
- A structure tree containing layout, text, and effects for the selected node.
The agent uses that structure to identify what the screen needs, then queries the design-system catalog for suitable components. The reference PNG stays available for the verification stage.
Giving the agent the right components
Searching the design-system catalog
We ship the design-system library with a built-in MCP server. The agent describes what it is trying to build in plain English, and the catalog returns matching components with their exact imports and supported APIs.
For example, a request to confirm a destructive action returns AlertDialog. The response includes its import from @internal/ui, available variants and sizes, the reason it matched, and ranked alternatives. That gives the agent concrete options and makes its selection inspectable in the transcript.

The catalog uses word and tag matching, with no embeddings or semantic search. It splits the request and each component’s name, description, hand-written tags, variants, and sizes into words. Exact word matches score 3, partial matches score 1, and an intent that equals a component tag scores 5. Results scoring zero are dropped, and the top five are returned with the terms that matched.
The destructive-action example works because that phrase is a tag on AlertDialog. In our catalog, warning popup returned nothing. Requests that match none of the indexed terms produce an empty list, so component authors have to maintain the vocabulary alongside the API.
The matcher is designed to make the reason for a result visible and to return nothing when it finds no match. Its coverage depends on the indexed terms, and partial-word matches still need interpretation. A short, inspectable ranking does not guarantee that the first component is the right one.
Mapping Figma properties to React props
Selecting a component leaves another question: which props represent the design? Figma’s labels and values do not necessarily follow the React API. Even a familiar property such as variant needs component-specific context: on a button it might describe primary, secondary, and text-only styles, while on an alert it might describe information, warning, and error states.
Alongside the catalog, we compile a binding manifest from the Code Connect files stored next to each component. Code Connect records the relationship between a Figma component and its implementation. We parse those local files at build time, so the agent can read the resulting metadata without querying Figma at call time. Consuming the generated metadata in CI requires neither a Figma token nor network access.
The generated artifacts are committed to the repository: a property map, a token map connecting CSS variables to Figma variables, and a drift file recording disagreements between the code and the design library. A renamed component or an unmatched variant can then appear in a reviewable diff.
The manifest has one entry per component, retaining Figma’s property metadata alongside the React component name. This Button entry shows a text property and a size variant:
{
"component": "Button",
"figmaProperties": {
"Button Text": { "key": "Button Text#37:10", "type": "TEXT" },
"Size": {
"key": "Size",
"type": "VARIANT",
"variantOptions": ["default", "xs", "sm", "lg", "xl - mobile only"]
}
}
}Button Text is the visible label; Button Text#37:10 is the full property identifier used by the Figma API. The suffix identifies the defining layer in the source metadata. TEXT specifies that the property accepts a string.
Visible property names can repeat within a component, so the stored identifier distinguishes them. Separate components have separate namespaces: Button and Input can each have a property named Size. Variant properties belong to the component set and use bare keys; variantOptions records their accepted values. Those Figma identifiers and options cannot be reconstructed from a React prop definition.
The snippet shows the Figma side of the relationship. Both components use a Figma property named Size; the corresponding values illustrate why the relationship to React must also be explicit:
| Component | Figma value | React prop |
|---|---|---|
| Button | "xl - mobile only" |
size="xl" |
| Input | "xl (48px) mobile only" |
size="xl" |
The two Figma labels express the same React size using different formatting. Preserving the component-specific relationship lets the workflow interpret those labels without relying on a naming convention that the library does not have.
Input also has a distinct text-property identifier and its own labels for the smaller sizes:
{
"component": "Input",
"figmaProperties": {
"Placeholder text": { "key": "Placeholder text#23711:35", "type": "TEXT" },
"Size": {
"key": "Size",
"type": "VARIANT",
"variantOptions": ["default (32px)", "lg (36px)", "xl (48px) mobile only"]
}
}
}Handling properties without a matching React prop
The manifest can also describe Figma properties that have no React binding. ButtonGroup has six Boolean properties controlling its buttons. Here are three of them alongside its orientation variant:
{
"component": "ButtonGroup",
"figmaProperties": {
"Button 1": { "key": "Button 1#18707:47", "type": "BOOLEAN" },
"Button 2": { "key": "Button 2#18707:40", "type": "BOOLEAN" },
"Button 6": { "key": "Button 6#18707:12", "type": "BOOLEAN" },
"Orientation": { "key": "Orientation", "type": "VARIANT" }
}
}The Boolean properties have suffixed keys; the orientation variant has a bare key. None of those six Booleans is bound to a React prop. Their presence in Figma is not enough to infer a corresponding React API.
When a requested component or prop value has no mapping, the binding tool returns a blocking error identifying the missing entry. That makes the gap explicit. A plausible substitute could render successfully while concealing that the mapping was never available.
Keeping Figma variables connected to CSS tokens
A design token gives a value a stable name, such as --base-background. Code that uses that name can pick up a palette change through the design system. A hard-coded #0b0b0c keeps its original value.
The token map records the relationship between CSS variables and Figma variables. A build step walks the token files, converts each leaf path into a CSS variable name, and reads the Figma variable ID that the design tool stored on that leaf. The output is committed and keyed by CSS variable name. A token without a Figma ID receives no binding and produces a build warning identifying the missing connection.
Binding checks use exact joins on generated CSS variable names and the recorded Figma identifiers. They do not establish a relationship by comparing how similar two colors look.
The current frame-extraction path still returns raw hex values, and the model chooses tokens for the generated code. Maintaining a token map does not mean this extraction step automatically resolves every color through it.
An ESLint rule rejects standalone hex literals in source. That enforces the use of named values where the lint configuration runs, but it cannot establish that the agent selected the correct valid token. Two tokens can share a color today and change independently later, so even a matching screenshot cannot prove that the semantic choice was right.
The shared bindings also support a reverse workflow that writes into Figma. In that direction, token lookup tries a fixed set of candidate variable names exactly and leaves a variable unbound if none matches. Property values must match Figma’s labels character for character; a wrong value can leave the property at its default even when the call succeeds. A variant="link" button with no Figma counterpart is reported as a skipped node. These are behaviors of the code-to-Figma path. The Figma-to-code extraction described above still passes raw colors to the model for token selection.
Enforcing design-system usage
The catalog and mappings give the agent information. A pre-write hook checks the content of its intercepted write and edit calls and blocks imports from eleven packages that our design system supersedes.
The rejection includes a next step. This excerpt shows how the hook builds the message returned to the agent:
process.stderr.write([
`Blocked: this ${tool} would import a package superseded by @internal/ui.`,
...hits.map((h) => ` - ${h.name} → ${h.hint}`),
"",
'Call mcp__ds-catalog__find-component(intent: "<what you are building>") instead.',
].join("\n") + "\n");
process.exit(2);The hook rejects the pending write before it reaches disk, and its error message goes back into the model’s turn. The agent can then query the catalog and revise the code before building more of the screen around an unsupported import.
ESLint also bans imports, but it sees files after they have been written. Its feedback reaches the agent when lint runs and the agent reads the result. The hook also works in consumer repositories where our ESLint configuration may be absent or the file may fall outside lint scope.
We use AST-based lint rules for checks that need more context, such as detecting utility classes that restyle a component where a supported prop exists. The hook can warn about that pattern while the agent writes; lint performs the more precise analysis. Those lint protections depend on the configuration being installed and run.
We also deny the generic code-generation tools that produced the original React and Tailwind output. Guidance alone did not reliably prevent their use in our setup: a vendor-provided skill directed the agent toward those tools. Explicit denial made that restriction part of the available tool surface.
These checks address code choices that can remain invisible in a screenshot. Once the agent has written the screen, visual verification checks its appearance and layout.
Verifying the rendered screen
Why the loop uses a browser
Verification runs through a separate MCP server with one main tool. The agent passes a local URL for the route it built, such as /preview/checkout, and the path to the reference PNG. The tool renders the route, compares it with the design, and returns a score plus two images for inspection. The agent uses the diff PNG when assessing the remaining differences.
The unit is a whole screen. That captures spacing, alignment, and composition errors that isolated component screenshots can miss.
A browser also makes the correction loop practical. Rendering the React app takes seconds in our setup, while a native build and simulator installation can take minutes. Browser verification provides a quick visual check; native behavior still needs its own testing.
The viewport uses the Figma frame’s CSS dimensions, and screenshots are captured at a device scale factor of 2. That factor controls the number of image pixels per CSS pixel. A direct pixel comparison requires the exported reference and captured screenshot to have matching pixel dimensions.
Making screenshots repeatable
Most of the verification tool’s work is preparing a screenshot that can be compared reliably. Animations, font loading, focus rings, and hover states can move pixels between captures even when the code has not changed.
The tool first disables motion and hides the caret and scrollbars:
const DISABLE_MOTION_CSS = `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
scroll-behavior: auto !important;
}
html { scrollbar-width: none; }
::-webkit-scrollbar { display: none; }
`;It then waits for fonts and network idle, blurs the active element, and parks the pointer. Applying the motion rules before the settling period prevents animations from running while the page is supposed to stabilize. Resetting focus and the pointer at the end avoids capturing incidental interaction states.
Before comparing pixels, the tool also checks the images’ aspect ratios:
const aspectDelta = Math.abs(refAspect - actualAspect) / refAspect;
if (aspectDelta > 0.02) {
return { ok: false, error: "aspect_mismatch" };
}An aspect-ratio difference above 2% produces a structured error. It can expose an incorrectly selected Figma node or incompatible capture dimensions before the tool returns a misleading pixel score. It is a preliminary check; two unrelated images can still share an aspect ratio.
The image-processing implementation, including its decode and resize routines, uses pure JavaScript. That costs some speed on large screenshots, but avoids native image dependencies that may have no prebuilt binary for the target machine or require a compiler toolchain to install.
The server is packaged as a standalone executable that runs without a separate Node installation. That makes it easier to distribute to developer machines and CI environments.
Deciding whether another pass is needed
The tool description gives the agent this exit rule:
pixelDiffScore < 0.02 OR a visual pass over the diff PNG
reports only sub-pixel or antialiasing differences.The comparison has two settings that measure different things:
| Setting | Value | Purpose |
|---|---|---|
| Per-pixel color tolerance | 0.1 |
Determines whether a pixel’s color difference counts toward the score |
| Overall acceptance cutoff | 0.02 |
Accepts a result when fewer than 2% of pixels count as different |
The comparator is configured with antialiasing detection off. Antialiasing smooths text and curves using intermediate edge colors, and those pixels can vary across renders and machines. Rendering noise can therefore remain in the comparison even with a per-pixel tolerance.
A score below 0.02 meets the numerical acceptance criterion. At or above that score, the agent can inspect the diff image and accept the result if the remaining differences are only sub-pixel or antialiasing artifacts. This visual judgment is part of the agent’s instructions.
The rule trades some sensitivity for a loop that can finish despite rendering differences. A small but meaningful error can occupy less than 2% of a screen, and visual inspection can also miss details. Passing this check therefore establishes a bounded level of visual similarity.
If the result fails, the automatic retry returns to the code-writing stage. It does not repeat component selection. That limits what the loop can repair when the initial component choice was unsuitable.
After three verification passes, the agent must stop and list any remaining gaps. Reaching the attempt limit does not count as success.
Catching later regressions in CI
The agent loop compares a newly built screen with a Figma export. Our Storybook suite checks whether library components have changed since their last approved screenshots.
CI captures each story in light and dark themes and compares it with a committed baseline. The suite currently has 760 baselines. A pull request fails if any comparison changes more than 0.1% of the pixels.
This is a separate comparison from the screen-level loop: it checks browser captures against previous browser captures, with a stricter cutoff for changes to individual stories.
Checking the capture itself
One assertion caught a problem that had affected every baseline. It checks the Storybook canvas scale:
const scale = window.parent.document
.querySelector("iframe[data-vitest]")
?.parentElement?.getAttribute("data-scale");
if (scale != null && scale !== "1") {
throw new Error(
`VR capture is scaled (${scale}); see contextOptions in vitest.config.ts`
);
}The browser provider had silently ignored our configured viewport. In the failing setup, a 393px mobile canvas was being rasterized at roughly 0.845 scale. Every committed baseline was soft, but the comparisons continued returning scores.
This canvas scale is separate from the screenshot’s device scale factor. The assertion checks that Storybook has not shrunk the content before capture. Downscaling changes the details the comparison measures and can make small regressions harder to distinguish.
The suite also waits for document.fonts.ready before each capture. Missing that wait accounted for most of our intermittent failures: fallback fonts changed the text metrics, and the screenshots differed even though the component code had not changed.
Limits that remain
Small changes can still fall below the 0.1% cutoff. On an image containing one million pixels, that fraction is a thousand pixels, enough for a localized error to matter while remaining small relative to the whole capture. The suite includes detection for portals and fixed-positioned content, but the overall percentage still needs to be interpreted in context.
The baseline updater introduces another limitation. It only rewrites an image when the comparison fails. A small copy change can pass under the threshold while leaving the old baseline committed. CI then continues accepting the difference against that stale reference.
Rendering also varies across machine architectures. Our baselines use a pinned container image, but that image is multi-architecture. Antialiasing can differ between an arm64 laptop and amd64 CI enough to push a one-pixel border across the threshold. In our observed failure, the cross-environment comparison failed in only one direction. A passing local comparison therefore did not establish that CI would accept the baseline.
When CI rejects a screenshot that appears correct locally, we inspect the failed run’s artifacts. If CI’s render contains the intended result, we use that render to update the baseline. This keeps the approved image tied to the environment that evaluates it.
Results and remaining coverage
The project has more than 60 components mapped with full Code Connect coverage. The case study reports that prototyping a typical enterprise screen went from about an hour to around ten minutes. One installation configures the workflow for Claude Code and Cursor, the two agents we have run it with. We have also observed fewer review rounds and faster handoffs, but have not measured either outcome.
We ran a smaller comparison to examine design-system usage. In one afternoon, we generated three screens once each with and without the tooling, then counted by hand the component roles for which the unguarded output used handwritten implementations or a banned library. The counts were about three for a simple screen, sixteen for a dashboard, and the mid-twenties for a detail view with a modal.
The more complex screens in that comparison involved more substitutions. That was a single, uncontrolled run with a hand count, so the pattern is an observation from those screens and the numbers are illustrative. It gives us a reason to investigate how component usage changes with screen complexity, but does not establish a general relationship.
Visual checks also leave behavior untested. An accordion can match its reference screenshot and still fail to expand when tapped. Its interaction flow therefore runs on a real device. My colleague Michał Pierzchała has written about using device-backed QA agents to test that part of the implementation.
The CI suite currently captures at a desktop preset. A second pass at mobile width is queued to cover narrow-screen regressions.
For teams building screenshot verification, I would start with an assertion that the content is captured at the intended scale. Ours exposed a failure that had softened every baseline without producing an error. Component rules, property and token mappings, visual comparison, and interaction tests each check a different part of the result. The capture assertion makes the visual part worth trusting.






















