Linktree · 2025–26
Project brief
Arbor is Linktree’s design system. I joined as a senior software engineer to lead its build, and spent the next year consolidating its micro-frontends onto one version and one token set. At first, the work was less about creating components and more about defining the naming conventions for the tokens they’d be made from.
Removing ambiguity is design work, not maintenance. It’s the part of a design system nobody notices until it’s missing. Think of a design system as a garden, and this as the pruning: cutting back whatever gets in the way of focus or understanding, so the rest of the work has more impact.
The problem
Primary button meant three different things, and each one was defensible.
Arbor had shipped alongside the legacy component library rather than replacing it. Both rendered on the same surfaces, at the same time, and each carried its own tokens. There was no shared source of truth between them, not because one had been removed, but because the new system arrived before there was anywhere to put one.
So the same name resolved differently depending on where you were standing. A primary button was a black field with white text in one place, a purple field with white text in another, and in at least one feature a secondary-level variant promoted by hand because that was the one that read correctly against everything around it.
Each of those made sense at the moment someone chose it. Nothing broke, so nothing flagged it. A button that renders looks like a button that works, and everyone who approved one was looking at a screen where it fit.
Two libraries rendered on the same pages, each with its own tokens, so “primary” had three answers and no rule for picking one.
Fixing that wasn’t a matter of picking one of the three. Arbor needed the standing to change the shape of the front-end codebase, which meant untangling module federation and making sure CSS loaded only once and in the right order. A design system can’t own appearance if it can’t control what CSS arrives, in what order, and once.
The diagnosis
It wasn’t a color problem.
Underneath the divergence sat a token hierarchy with no levels in it. No scoping. No naming conventions describing where in a component’s structure a token applied. Everything flat, on one level, sharing relationships.
You can’t have a primitive token that also acts as a component token.
A value can’t be both the raw material and the application of the raw material. The moment it’s both, every consumer of the primitive inherits a decision that belonged to one component. And the only way to correct that component is to change something a hundred other places are reading.
Left: each token level does one job. Right: one value does both, so changing it for one component changes it everywhere the primitive is used.
The team had run into this friction before I arrived and had reached a decision about it: never add another layer of component tokens. As I understand it, the thinking was that to the designers who owned token creation in Figma, a second layer seemed more complicated, and keeping everything on one level was simpler.
That was a reasonable call, made by the people who would have had to maintain the thing they were declining, and a second layer does add moving parts. The trouble is that declining it produced the complexity. The structure they were avoiding was cheaper than the ambiguity they got instead.
A simplification that costs more than the structure it avoids is very hard to see from the inside, because on the way in it reads as prudence.
And none of it errors. Nothing looks broken. The contradiction surfaces only when somebody tries to add the second level and finds it already occupied.
Scope
The design-systems function didn’t exist when I arrived. For the first few months it was me.
A staff product designer joined afterward and we paired on building and delivering the system. He spearheaded the Figma component library and owned that half of the equation: he sat on the design team and worked daily with the designers shipping features who needed opinions about which components to use where. I owned the code.
Two of us, and more than ten micro-frontends.
We kept it working with a few standing meetings: weekly office hours with the entire design team, a standing 1:1 with the staff designer that ran his engineering opinions into my design work and my design opinions into his, and a weekly meeting with the two product design teams driving the visual language. That last one existed to read where things were heading, surface problems while they were still cheap, and make connections between different teams’ work that nobody else was positioned to see.
Step one: legibility
One Tailwind configuration. One component library. One token set.
This part looks like a big Tailwind migration from the outside. It wasn’t, for the reason above: you can’t debug a system you can’t read. Two token sets overlapping meant that when something rendered wrong, there was no reliable way to determine which one had produced it. And no way to correct it in one place, because there was no one place.
Unifying how surfaces mounted and loaded wasn’t normalization for its own sake. It was making one library’s CSS arrive once, in a known order, so that a token could have a single answer.
Before: two token sets define the same name and nothing decides which wins. After: one set, one answer.
The method
Audit what’s actually consumed. Remove what isn’t. Write the codemod. Then change.
@linktr.ee/arbor went from version 9 to version 27, eighteen major releases, while every micro-frontend stayed on one version and one token set throughout. That’s only possible if migration is a first-class, automated capability rather than a request made of other teams.
We shipped the codemods as their own package, for upgrading Arbor across major versions and migrating off the legacy component library. Most of those majors removed things nobody was using. I labeled them major anyway, so the version number always told the truth about risk.
Governance, and the tax it charges
Intention encoded in the commit. CI enforcing the format on push. A release script deriving each package’s version from those commits. The version flagging the pull request for human review. And the written record letting the reviewer approve confidently.
The version number says this is risky, and the written context says here’s exactly why it isn’t. The tax and its remedy were designed together. Over-versioning on its own would have produced friction and nothing else. With the context attached, it produced a conversation at precisely the moment someone was deciding.
The objection to all of this is that it’s slow. A codebase that was updated too quickly needs slow thinking. The friction isn’t a cost of the method. It is the method, and it’s what produced the conversations that aligned the team and made the migration land.
The judgment
I shipped the rename before I’d shipped a way to migrate to it, and had to pull it back.
The clearest instance of the flat hierarchy was in type. The brand typeface ships as two cuts in one family — Display, which is more extended, and Product, whose width reads as body copy — and which cut a token resolved to was a value someone had to know rather than a name they could select. That’s a primitive carrying a component’s decision, in the one corner of the system where I could fix it by renaming.
I renamed the family to Product, promoted Display to a peer primitive, and exposed both as named utilities from a single variable font file, so choosing correctly cost nothing. The semantics were right. What I hadn’t shipped was a migration path. Consumers had to carry the change themselves.
So I reverted it at the next major boundary, un-shipping my own work to protect the surfaces the project existed to fix. Then I built the codemod, hardened it against the cases that actually occur (!important prefixes, class-merging groups, headings that had lost their weight), and taught it to flag adjacent font-family utilities, so a hand-written declaration sitting beside a migrated one couldn’t pass silently. The rename re-landed inside the typography core three weeks later and held.
You can’t rename a token. You can only migrate one.
Escalation
The standing complaint about design systems is that developers don’t change their habits. Three responses, and each rung moves closer to the moment of decision while asking less of the person, not more:
Documentation asks them to remember it exists, go and find it, and apply it. A lint rule in CI and locally flags the problem at the moment of writing, and asks them to work out the fix. A reviewer that annotates pull requests hands over the code to write, and asks only that they accept it.
That last one came out of auditing Arbor against the best systems in the field and noticing the gap in our own workflow. The research didn’t only answer component questions: it produced the roadmap for the system’s own tooling.
The goal is to shrink the distance between knowing and doing until the right thing costs less than the wrong thing.
The closer a tool sits to the moment someone’s deciding, the less it asks of them. Docs still matter; they’re just the furthest away.
Closing the loop on truth
Building an agent surface is now common. Linktree pitched Arbor as “Linktree components, one prompt away”, working with Claude, Cursor, and VS Code.
What I’d argue is less common is testing whether it’s true.
We built evals that measured whether the agent surface changed what a model actually produced. Effect, not existence. Then a CI fixture that asserted the guidance was true rather than that a model had complied with it. That distinction is the whole point: a model complying with a false claim is worse than a model ignoring it, and measuring compliance would have produced a nicer number and taught us nothing.
The eval caught a few claims in our guidance that weren’t true, and we fixed them. We added provenance to the guidance artifacts so a consumer could see where a claim came from, and status flags to every component so a consumer could ask may I recommend this? rather than guess.
Unification made ambiguity in the code legible, because you can’t debug what you can’t read. Evals made ambiguity in the guidance legible, because you can’t trust what you can’t test.
The accessibility work followed the same shape. An audit found gaps in several hundred places. We counted them, fixed them, and added a gate so the number couldn’t grow, plus warnings that catch the most common cause while someone’s writing the code.
What’s true now
Tokens sync bidirectionally between code and Figma with no manual step. Every pull request that changes a token pushes to Figma — to main, or to a matching branch, since the naming conventions are shared and so the branch mapping could be automated too. It’s linted, it runs as a GitHub Action, and it logs what changed, what needs adding, and why.
The documentation builds itself, and is readable by people and by models through a documentation site, an MCP server, and a CLI.
Two things stopped happening, which is the evidence I’d point at first: manual token syncing, and documentation going stale.
Write something once and then point at it.
The guidance is written once and reaches all five places decisions get made, including an agent, which reads it exactly the way a person does.
What comes next
Bidirectional sync means design and code can’t silently disagree anymore. Structurally, drift is gone.
What remains once drift is gone is ambiguity. And the two aren’t the same thing. Drift is when design and code disagree and one of them is wrong. Ambiguity is when they disagree and neither is wrong.
That’s what I’m working on now.