Skip to main content
Bartek Czyż
← writing
9 min read
  • Design System
  • figma
  • Design Tokens
  • automation
  • ai

Design system: reading Figma is pattern recognition

by Bartek

Design system: reading Figma is pattern recognition
Photo by Ryan Ancill on Unsplash

Part 5 of a five part series on building a design system.

Our design system ships around 770 icon components and 60-odd illustrations, all generated from Figma by a script. Nobody has hand-written an icon component in three years.

Before writing that script I assumed the hard part would be the SVG. It is not. Turning an SVG into a React component is a solved problem, and an off-the-shelf tool does it in a few lines.

The hard part is everything that happens before you get to the SVG: finding the right nodes in a file with thousands of them, working out which variant each one is when that fact is recorded three different ways, and surviving an API that returns nulls and rate limits you. Nearly all of the code exists for those reasons, because a Figma file is a document maintained by people over years, and it drifts the same way a codebase drifts.

What the naive version looks like

The pipeline everyone imagines is short. Call the Figma API, find the icons, download the SVGs, run them through a transformer, write the components out. You could write it in an afternoon, and I did.

Then you run it against a real design file that has been alive for two years, and you meet the actual problem.

Failure one: the code depends on layer names

Finding the icons means walking the document tree. Here is roughly what that looks like:

typescript
const icons = canvas.children
  .find((child) => child.name === 'Base')
  ?.children.find((child) => child.name === 'Content')
  ?.children.map((child) => ({ id: child.id, name: child.name })) ?? [];

Look at what that code is coupled to. Not an ID, not a type, not anything structural. It is coupled to a designer having named a frame “Base” and a frame inside it “Content”.

The day someone renames “Content” to “Icons”, or wraps it in a tidying group, this returns an empty array. Note the ?? [] at the end: it does not throw. It succeeds, quietly, with nothing. Which is why we added a validator that treats an empty icon list as a hard error, because a pipeline that silently generates zero icons is worse than one that crashes.

That coupling is not a bug we could have designed away. Somewhere, code has to find the icons, and the only handle Figma gives you is structure the designers control. What you can do is fail loudly when the handle moves.

Failure two: two conventions living in the same file

Icons come in variants: stroke, solid, contrast, and a couple of duotone styles. Figma has a proper mechanism for this, component sets with a variant property, and the newer icons use it. The older ones predate that decision and encode the variant in the name.

So the parser handles both:

typescript
const match = fileName.match(/^(.*?)-(stroke|solid|contrast|duo-stroke|duo-solid)\.svg$/);

if (match) {
  const [, base, variant] = match;
  icons[base][variant] = fileName;
} else {
  // Legacy icons with no variant in the name are all stroke.
  icons[fileName.replace(/\.svg$/, '')].stroke = fileName;
}

That else branch is three years of history in five lines. Somebody could go and rename every legacy icon in Figma to match the new convention. It would take a day, it would break every downstream reference, and it would deliver nothing a user notices. So the convention shift stays permanent, and the code carries both.

Most design system automation looks like this. Not elegant transformation of clean input. Two conventions and a comment explaining the older one.

Failure three: the variant is not always where it should be

Even within the modern component sets, the variant property is not reliably set. So reading it is a chain of decreasing confidence:

typescript
// 1. The correct way: Figma's variant property, "Style=Stroke".
const styleMatch = /Style=([^,]+)/i.exec(component.name);

if (styleMatch) {
  return normaliseVariant(styleMatch[1]);
}

// 2. Someone put the variant in the name but never set the property.
const named = variants.find((variant) =>
  component.name.toLowerCase().includes(variant.replace('-', ''))
);

if (named) {
  return named;
}

// 3. Give up, log it, assume the most common case.
console.warn(`Could not determine variant style for ${component.name}, defaulting to 'stroke'`);

return 'stroke';

I want to be clear that this is not a complaint about designers. Our designers are good, and this file has thousands of nodes maintained over years by several people through two design language changes. Perfect consistency at that scale is not a realistic standard for a human working in a visual tool, any more than perfect consistency is realistic in a codebase of the same age without a linter.

The point is what the code has to be. It is not a transformer. It is a set of hypotheses about intent, ordered by confidence, with a logged fallback for when they all miss. That is pattern recognition, and it is the actual skill in this work.

The unglamorous half

The rest of the generator is fighting the API, and it is worth saying out loud because nobody mentions it in the tutorials.

Figma returns a null URL when a node cannot be rendered as an image, for an empty layer, zero-sized bounds, or a render timeout. Request those anyway and you get 404s that abort the run, so they get filtered out first and reported.

Ask for too many image URLs at once and you get a 413. So URL requests batch at fifty and downloads at ten, with short sleeps between batches, sub-batching on failure, and Promise.allSettled so one bad icon does not kill a fifteen minute run.

Duplicate names are a hard error, because two icons with the same name is a collision that would otherwise silently overwrite a file. That check exists because it happened.

None of this is interesting. All of it is the difference between a script that works on the demo and a script the team can run on a Tuesday.

What we did instead of configuring everything

The temptation with this kind of pipeline is a config file with an entry per icon. That does not scale to nearly eight hundred of them and it rots immediately.

Instead each source of icons declares its traits:

typescript
export const baseIcons = new IconSource({
  outDirectory: 'base',
  traits: {
    sizes: true,
    monoColor: true,
    types: true,
  },
  getData: (canvas) => findIconNodes(canvas),
});

monoColor means the icon is a single colour that should follow a token, so the generated component gets a color prop defaulting to text.body. sizes means it is scalable, so it gets a size prop defaulting to 24 and the SVG’s own dimensions are stripped. types means it has variants, so the generated component takes a type prop and switches between them.

Three booleans per source, and the props of all of them fall out of that. The pattern is recognised once by a person and then applied by a machine, which is the right division of labour.

There is one thing traits cannot capture. Some icons must mirror horizontally in right-to-left locales: a back arrow, a reply arrow. Others must not: a clock, a logo, anything with a letterform. Figma does not know this. It is not a property of the drawing. It is design intent that exists only in someone’s head, so it lives in a hand-maintained list in our repository, and it is the one part of the pipeline that requires a human every time.

The conversation this forces

Automating icons is a technical problem with a technical solution. Everything else about reading designs is a conversation, and the pipeline made that unavoidable in a useful way.

Once the icons are generated from the file, the file becomes the source of truth in a way it was not before. Renaming an icon in Figma renames a React component and breaks the build. That sounds fragile and turned out to be a feature: it made the cost of design file changes visible to everyone, including the designers, which is the beginning of a real conversation rather than a complaint.

The rule we ended up writing down for components was blunt: if you think the design is a mistake, confirm it with the designer before you implement it. Style variants belong in the design system as a prop, never as a one-off at the call site.

That rule has an obvious failure mode, which is a developer overriding a designer’s judgement based on a hunch. It works because it is a question rather than a veto. Most of the time the answer is “good catch, use the existing one”. Sometimes the answer is “no, this genuinely needs to be different, here is why”, and then it becomes a variant in the design system where everyone can find it. The value is not that developers win. It is that the difference gets resolved once, in the open, instead of quietly shipping.

Which brings us to 2026

Since Figma’s Dev Mode MCP server arrived, a model can read your design file directly. It gets the node tree, the auto layout rules, the variables and the component relationships instead of a screenshot. It is genuinely a large step up, and if you have not tried it you should.

It also fails in a way that is going to sound familiar.

The output is widely reported as around eighty-five percent right, with a predictable fifteen percent that is wrong. It is inconsistent across components in the same file: one report describes generating a screen where some buttons came out with their pressed states and others did not, despite every button having the same variants defined, because the tool renders full state branching for some components and only the default for others depending on complexity. And there is no feedback loop, so even when you tell it to use your Button, it cannot see its own rendered output and will invent a one-off margin or colour when it hits something outside the map.

Above all, the results depend almost entirely on how well structured the file is. Teams with mature, consistently-built design systems get dramatic results. Teams with drifted files get something not much better than pasting in a screenshot. Figma’s own guidance says it plainly: without the code mappings, the model is guessing.

Read those failure modes next to the fallback chain earlier in this post. Style=Stroke, then a substring guess, then “could not determine, defaulting to stroke”. That is a language model’s failure mode, written by hand, in 2024, for the same reason.

The tooling got enormously better and the bottleneck did not move, because the bottleneck was never the reader. It is the file. Every hour a design team spends on consistent naming, properly configured variant properties and component sets that mean what they say, pays out to a script, a new designer, a developer reading the file at 5pm, and now a language model. That work was always the highest leverage thing in the design half of a design system. It just used to be easy to skip, because only the developers suffered and they suffered quietly.

This is the last post in this series. If there is a single thread running through all five, it is that the durable parts of a design system are the ones where correctness does not depend on somebody remembering: tokens a component cannot bypass, documentation generated from the source, screenshots that fail the build, a design file structured well enough that a machine can read it. Everything else is a preference, and preferences do not survive three years of Fridays.

Discussion