A practical Figma to Flutter design handoff system: read files as specs, map auto layout to flexbox, sync design tokens, and use golden tests to kill UI drift.
A designer once handed me a Figma file with the note "it's pixel-perfect, just build it." Three days later the two of us were standing at my desk in our Dubai office, staring at the staging build next to the design, both quietly sure something was wrong and neither able to name it. The colors matched. The fonts matched. Screenshots laid on top of each other were 90% identical. But the 90% version looked cheaper, and we couldn't say why until we started measuring.
That gap — the last 10% between a beautiful Figma file and the thing users actually touch — is where most "the developer didn't follow the design" arguments live. After six years of shipping Flutter and web UI, here's the opinion I'll defend: that gap is not a discipline problem or an attention problem. It's a systems problem. You close it by deciding, up front, how each kind of design decision maps to a code primitive, so drift has nowhere to accumulate. Eyeballing screenshots at the end is how you lose. Mapping the file to your system at the start is how you win. What follows is the design-to-code handoff process I actually use — the same one that took a recent dashboard rebuild from "the spacing feels off" review comments to basically none.
The naive model of a handoff is: designer produces a picture, developer reproduces the picture. Under that model, every property is a fresh decision the developer re-derives by looking. That padding looks like 16. That gray looks like the other gray. That radius is "rounded." Multiply a few hundred of these tiny re-derivations across a screen and each one carries a small error. Individually invisible. Collectively, "it looks off."
The 10% almost never hides in the loud stuff. Nobody ships the wrong brand color — that gets caught in five seconds. The drift lives in:
None of these show up in a screenshot comparison at normal zoom. All of them show up in how the product feels. So the goal isn't "match the picture." The goal is to reconstruct the decisions behind the picture, because decisions are what compose into a coherent whole. A pixel is an output; a decision is the thing that generates consistent pixels everywhere else in the app.
The single biggest shift is to stop treating the Figma canvas as an image and start treating it as a structured document. A Figma file is a tree. Frames contain frames contain elements, each node carries constraints, layout rules, and bound variables. If you only look at the rendered pixels, you're throwing away the most valuable half of the file — the part that tells you why it looks the way it does.
Before I write a line of UI code, I go through the file and answer a fixed set of questions:
In Figma's Dev Mode you can inspect all of this directly, and the plugin ecosystem will happily dump measurements at you. I don't trust the code export blindly — auto-generated code is a starting map, not the territory. It hands you nested Containers and magic numbers with none of the reuse or tokens that make the code maintainable. But I absolutely read the inspect panel like a contract. If I can't tell whether a value is a token or a one-off, that's a question for the designer, asked before I build, not after. A thirty-second question up front beats a review-cycle argument later.
Once you see it, you can't unsee it: Figma's auto-layout is flexbox wearing a friendlier coat. The mapping is almost one-to-one, and internalizing it removes an entire category of guesswork. Here's the full translation table I keep in my head:
flex-direction (row / column), or in Flutter, Row / Column.gap on the web, or a SizedBox / spacing separator in Flutter.padding, obviously, but read all four sides; designers set them independently more than you'd think.justify-content + align-items, or mainAxisAlignment + crossAxisAlignment.width: fit-content / MainAxisSize.min.flex: 1 / Expanded.Here's a card row translated straight from its auto-layout properties into Flutter. Nothing here is a judgment call — every value comes from the file:
// Auto-layout: horizontal, gap 12, padding 16, align center, hug height, fill widthContainer( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ const Icon(Icons.folder, size: 20), const SizedBox(width: 12), Expanded( // child set to "fill" in Figma child: Text('Project files', style: context.text.bodyMedium), ), const SizedBox(width: 12), const Icon(Icons.chevron_right, size: 20), ], ),);And the same thing on the web, where the mapping is even more literal:
.card-row { display: flex; flex-direction: row; align-items: center; gap: 12px; padding: 16px; width: 100%; /* fill */}.card-row__label { flex: 1; } /* the "fill" child */When you build UI this way, the layout can't drift, because you're not inventing structure — you're transcribing it. The auto-layout tree becomes your widget tree. On a recent dashboard we rebuilt, doing this mechanically cut the "spacing is slightly off" review comments to basically zero, because there was no manual spacing left to get wrong. The mental discipline is simple: if you're typing a number that isn't in the file, stop and ask where it came from.
This is the part people skip, and it's the part that pays for itself ten times over. Figma variables (and the older color/text styles) are a design token system. Your codebase has, or should have, its own token system. The whole game is making those two systems isomorphic — same names, same structure, same values — so a color in Figma has exactly one home in code.
If your design has color/surface/raised and your code has a Colors.surfaceRaised, a human never has to translate a hex again. When the value moves, it moves in one place on each side, and the sides stay in sync. When the mapping is fuzzy — when the designer says #F4F4F5 and you go find "the closest gray we already have" — you've just introduced a permanent, invisible source of drift that no screenshot will ever catch.
My rules for tokens:
color/action/primary, not blue/600. The palette layer can change under it. This mirrors exactly how good Figma variable setups are structured — primitives feeding semantics, and only semantics touching components.space.6.Here's the shape I aim for. The primitive layer is boring on purpose, and the semantic layer is what components actually consume:
// Primitives — mirror Figma's primitive variable collectionclass Palette { static const zinc100 = Color(0xFFF4F4F5); static const zinc900 = Color(0xFF18181B); static const blue600 = Color(0xFF2563EB);}// Semantics — mirror Figma's semantic/aliased variablesclass AppColors { final Color surface; final Color textPrimary; final Color actionPrimary; const AppColors.light() : surface = Palette.zinc100, textPrimary = Palette.zinc900, actionPrimary = Palette.blue600; const AppColors.dark() : surface = Palette.zinc900, textPrimary = Palette.zinc100, actionPrimary = Palette.blue600;}The payoff is that dark mode, rebrands, and white-labeling stop being a slog. On one product where a client wanted their own theme, swapping the semantic layer took an afternoon because nothing downstream referenced a raw color — the buttons asked for actionPrimary and got whatever the active theme handed them. If we'd sprinkled hex codes through the widgets, that would've been a week of grep-and-pray. This is also where Figma's variable modes (light/dark/brand) map cleanly onto your theme classes: one mode per constructor, and the component code never changes.
If the designer made a component, you make a component. One-to-one. This sounds obvious and is violated constantly, usually because the developer builds the first screen by slapping widgets together and only later notices the same card shows up five times.
A Figma component has variants and properties — size=sm|md|lg, state=default|hover|disabled, hasIcon=true|false. Those are not decoration; they're the component's API. Translate them into your component's actual parameters, and now the two definitions agree on what the component is, not just how one instance of it looks.
enum ButtonSize { sm, md, lg }class AppButton extends StatelessWidget { final String label; final ButtonSize size; // Figma variant: size final bool isDisabled; // Figma variant: state=disabled final IconData? leadingIcon; // Figma prop: hasIcon final VoidCallback? onPressed; const AppButton({ super.key, required this.label, this.size = ButtonSize.md, this.isDisabled = false, this.leadingIcon, this.onPressed, }); // ...}The trap to avoid: building to the instances you can see rather than the component that generated them. If you only ever saw the medium button in the mockups, and you hardcode medium, the day someone needs a small button they'll fork it, and now you have two buttons that drift apart forever. Build the component with its full variant surface up front, even the states that aren't on any screen yet, because the Figma component already told you they exist.
I keep a running checklist per component:
size, every type)?When those three agree, "does the build match the design" stops being a question you answer by squinting. It's decidable by reading two lists side by side.
Some of the last 10% is not in the file at all — it lives in the designer's hands. Optical adjustments are the classic case. A play triangle centered geometrically in a circle looks off-center; it has to be nudged right to look centered. An icon next to text often needs a hair of optical alignment that no measurement will hand you. Designers do these by feel and rarely annotate them, which means the inspect panel is silent about exactly the things that make a UI feel hand-crafted.
You catch these by knowing they exist and looking for them:
BorderSide.strokeAlign and CSS box-sizing both exist to make this a decision rather than an accident.The move that saves me here is normalizing against the scale. When I inspect a value like 18 or 20 that isn't on our spacing ramp, I don't just copy it — I flag it. Nine times out of ten it should be 16 or 24 and the designer nudged something by accident. The tenth time it's intentional and now I know it's load-bearing. Either way I've converted a silent guess into an explicit decision, which is the whole philosophy of this workflow in miniature.
Reading the file well prevents most drift. Catching the rest needs a ritual, because at some point you have to compare the real render to the intended one — and "looks about right" is not a comparison.
My cheapest, highest-value habit: take a screenshot of the running build at the exact design frame size, drop it into Figma as a layer directly on top of the design, and set it to ~50% opacity or a difference blend mode. Misalignments jump out instantly. Text that's two pixels low, a card that's four pixels too wide, a shadow with the wrong spread — all of it becomes obvious when the two are literally superimposed. The eye is terrible at absolute judgment and excellent at spotting a doubled edge, so give it a doubled edge to find.
For anything that ships repeatedly, I push this into CI with visual regression tests. In Flutter that's golden tests; on the web it's a snapshot tool like Playwright's screenshot assertions.
testWidgets('AppButton md matches golden', (tester) async { await tester.pumpWidget( const AppScaffold(child: AppButton(label: 'Save')), ); await expectLater( find.byType(AppButton), matchesGoldenFile('goldens/app_button_md.png'), );});A golden won't tell you the design was translated correctly — it only pins the current render so it can't silently change later. The one-time overlay-in-Figma pass is what tells you the translation was faithful in the first place. You need both: the overlay to reach fidelity, the goldens to keep it. A useful rule of thumb — golden-test the component library, not full screens. Component goldens are stable and diff cleanly; full-screen goldens churn on every content change and get muted, which defeats the point.
Here's the counterintuitive part, and the reason I said "faithful," not "identical." Sometimes matching the Figma file pixel-for-pixel is the wrong thing to do. A static design is a snapshot of one state at one width with placeholder content. Production is dynamic: real names are longer, real lists are empty or have 400 items, real text wraps, real screens come in sizes the designer never drew.
When reality and the mockup disagree, you translate intent, not pixels:
The way I keep this honest: when I deviate from the literal file, I say so, and I say why, and I get a nod. "The design shows a fixed 320px sidebar; I'm making it collapse under 768 because there's no tablet frame and this is what the constraints imply — good?" That one sentence prevents the design-vs-dev standoff, because now the deviation is a shared decision instead of a thing the designer discovers in review and reads as sloppiness. The failure mode isn't deviating — it's deviating silently.
Closing the Figma-to-production gap is not about being more careful. It's about building a translation system so carefulness isn't the load-bearing thing.
Do this consistently and the whole "the developer didn't follow the design" argument evaporates, because there's no longer a gap for anyone to point at. The file and the build are two views of the same system.