manu·martínez-almeida

Engineering

Figma to Code Before LLMs

Jev launched last week, and people are already sorting inboxes and even playing Doom with it. It uses a model to make structured decisions inside a larger algorithm, and I had seen that pattern before. When I built the first version of Figma-to-code at Builder.io, I used a much narrower classifier. Our visual editor let marketers and designers place things where they looked right without learning the HTML mental model, but the result still needed a responsive layout.

The Is This a Pigeon? anime frame: a man in glasses holds out his hand toward a yellow butterfly. me, on X Jev Is this my old model?
Is This a Pigeon? template

Figma and the web both represent documents as trees, but a Figma tree can be a much bigger mess. Nodes sit wherever the composition needs them, multiple frames overlap to create effects that could be a single CSS line, old iterations still exist hidden under some opaque white rectangle… I have seen it all.

Frontend developers take that composition and decompose it into rows, columns, and constraints that can respond to different screen sizes. Copying every node into an absolutely positioned element looks right at one screen size and breaks at every other.

A landing-page design open in Figma

Figure 1. A Figma design. Its groups do not necessarily match a responsive web layout.

Responsive output shown side by side at desktop and mobile sizes

Figure 2. Responsive output at desktop and mobile sizes.

Data dominates

As a big fan of Rob Pike, I started thinking in terms of data structures. A Figma design could be a single node or a 30 MB monstrosity, while the decision-tree models I wanted to try expected a fixed number of inputs.

Models can work on variable-size inputs, including graphs, but a good-looking Figma design could be a flat frame with 400 sibling nodes. Nothing in that frame says which nodes belong together, and converting it one to one would make a terrible website. Figuring out that grouping was the whole problem!

The breakthrough

We had the model compare two possible merges. We could use those comparisons to choose which nodes to group, then repeat with the new groups, instead of asking a model to turn the whole Figma tree into responsive HTML.

Imagine rendering a responsive website, then throwing away its hierarchy. Keep each visible element’s bounding box, its position and dimensions, in a flat array:

nodes = [node1, node2, ..., nodeN]

A candidate merge proposes joining two nodes or groups under a new parent. One candidate might join a heading with its paragraph, another might join two neighboring cards. The model compares them and predicts whether both merges are valid, neither is valid, or only one is valid.

compare(candidateA, candidateB)
  → probabilities for [both valid, neither valid, only A valid, only B valid]

Each candidate became 21 numbers describing its geometry and structure: group dimensions, their enclosing box, alignment, distances, child counts, and information about alternative merges. Two candidates gave us 42 input features, regardless of the size of the design. The model did not read the text or look at colors.

After experimenting with different models, we settled on gradient-boosted decision trees, using XGBoost. A decision tree follows branches based on its inputs to make a prediction, and boosting combines multiple trees. Speed mattered because rebuilding a hierarchy meant repeatedly comparing candidates in a tournament, applying compatible winners, then building the next round.

Training data

The internet is our dataset! Take a responsive website and the original HTML gives us a tree to learn from. Render it with Chromium, compute the relevant elements’ bounding boxes with getBoundingClientRect(), and keep their parent-child relationships as the known answer.

After combining redundant nested rows and columns, we labeled a candidate merge valid when its two groups belonged to the same expected parent. Two independent branches could both have a valid merge, so we didn’t need to prescribe a single order for reconstructing the page. Comparing two candidates gave us one of the four labels:

...candidateA.features, ...candidateB.features, matchupLabel
         21 values              21 values       one of 4 classes

A single layout could supply many rows for our training CSV as we reconstructed its hierarchy and encountered new candidates.

From known parents to a training row

Known parents, before training
Figure. Labels computed from the known parents, before model training.

Keep the answer before flattening

The dashed outlines show the known parents: h1, p and btn belong to “copy”; card1, card2 and card3 belong to “cards”.

Check one proposed merge

Try joining btn and card1. Their box passes the geometry check, but they have different parents, so we label this merge invalid.

Turn pairs into training examples

Compare two proposed merges: A joins h1 + p, B joins btn + card1. A has a shared parent and B does not, so this training row gets “only A valid”.

Reverse the inputs

Now put btn + card1 first and h1 + p second. The merges stay the same, but the label becomes “only B valid”. That gives us a second training row.

Both merges can be valid

Compare h1 + p with card1 + card2. Each merge has a shared parent, so the label is “both valid”. We repeat this check for every ordered pair of different candidates.

We manually selected good websites, trained the model, and tried it against other websites and designs. The designs it could not reproduce guided what we added to the next training set. Data collection and training were heavily coupled and order-dependent, but we ended up with a small dataset covering a variety of designs and website structures.

We could also inspect the trees for suspicious rules, but reasonable-looking conditions could still fit only the training examples. We still needed to test on designs outside the training data.

A tournament between merges

With 1,000 nodes, there are 499,500 possible pairs, before we even compare possible merges with each other. Our first algorithm filters out pairs that geometrically cannot occur.

For separate boxes, a merge’s bounding box must not intersect a third node. A heading and its paragraph might be a candidate, while the heading and a card at the bottom of the page would enclose unrelated content.

We handled containment before this search, recursed into children, and kept existing auto-layout structure. The demos use separate boxes to leave those cases out.

We then compare every candidate against every other candidate. With mm candidates, that gives us m(m−1)/2m(m-1)/2 matchups. The model’s most likely class determines how we update their scores:

If “both valid” wins with probability 0.8, each candidate gains 0.4. These contributions accumulate across matchups, so tournament scores can exceed 1 or fall below 0.

Two merges enter the comparison

matchups 0 / 10 selected — top score —
Figure. Recorded XGBoost probabilities on an invented layout; the browser accumulates the scores and selects the winner with the September 2023 rule.

Start with the legal merges

Each bar is a possible merge. Five candidates need ten comparisons to complete this tournament.

Ask a small question

The model returns four probabilities. Here “both valid” wins, so each candidate gets half that probability.

Let every candidate play

The remaining matchups update the scores. A “neither valid” prediction would subtract from both candidates.

Take the highest score

This round selects the heading and paragraph. Winners must also have non-overlapping rectangles, so we can apply their merges in the same round.

We sort by score and select the highest-scoring merges whose rectangles do not overlap. Two equally good candidates may share a node, in which case one has to wait.

The September 2023 implementation used the maximum score as its cutoff, or 95% of that maximum for more than 20 candidates. Later, k-means split the scores into two clusters, and we used how tightly they clustered to help adjust the cutoff. These demos use the earlier rule with recorded predictions from the currently checked-in model; scoring and merging run in the browser.

Building the tree

After each tournament, the merged groups become nodes for the next round. A heading and paragraph become a column, join a button, then join an image in a row. The model keeps answering the same question about larger groups, and we keep their children to build the tree.

Watch the hierarchy come back

groups 8 merges 0 last —
Figure. Geometry, tournament scoring and merges run here. XGBoost predictions were recorded for this invented layout; the selection rule is from September 2023. Blue groups are rows, maroon groups are columns.

Begin with the boxes

We start with eight rectangles and their positions, with the hierarchy removed.

Reject impossible groups first

Joining the heading and button would enclose the paragraph, so geometry rejects it. Joining the heading and paragraph passes.

Run the tournament, then repeat

After each round, we recompute the candidates. Two independent merges can happen together.

There is a tree again!

The tree has one root after seven merges. Redundant nested rows combine into one row, and nested columns into one column.

The main loop looks roughly like this:

while more than one group remains:
    candidates = geometrically possible merges
    if there is only one candidate:
        merge it
    else:
        compare candidates in a tournament
        choose high-scoring, non-overlapping merges
        apply the chosen merges
    stop if no progress is possible

When geometry blocked progress, one fallback temporarily removed a group, solved the rest, then joined it back. The algorithm could still fail, or finish with a hierarchy a developer would not have chosen.

I used AIR to inspect each merge and its scores. Here is the original tool running again locally:

Original Builder AIR inspector showing merge steps, candidate scores, and the recovered bounding-box groups for a landing-page section

Figure 3. The original internal tool. On the left, reconstruction steps and scores; on the right, the groups at the selected step. Open the image to inspect it at full size.

From hierarchy to layout

The model only recovered the hierarchy. We got colors, typography, borders, and other inline styles directly from the Figma nodes. Heuristics then decided alignment, margins, gaps, padding, and sizing.

The recovered groups let us make layout decisions locally: spacing between cards in a row, or alignment within a column of text.

Real Figma files

Hierarchy extraction worked very well, but getting production-ready results was much harder when the team had not been careful with the Figma designs. For some customers it worked so well, and for others it was a disaster.

Even with a plausible hierarchy, hidden iterations and overlapping shapes could still make the output unusable.

AIR fixture list with 245 Figma checks passing, while the two visible fixtures have separate Fail AI badges

Figure 4. Separate checks in AIR. The “245 passed” heading counts Figma preprocessing checks. These visible fixtures pass that check but fail the expected hierarchy comparison, labeled “AI”. This is a local test run, not a model accuracy benchmark.

What I still like about this project is that hundreds of design nodes became comparisons with just 42 inputs.

← All writing