Linktree · 2025–26
One system across product teams
Arbor is Linktree’s design system. I joined as a senior software engineer to lead its build, and spent the next year consolidating Linktree’s 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.
The work crossed design and engineering. I researched component behavior, worked with designers on the conventions, and built the delivery and migration tools that carried those decisions into production. A Figma plugin, a CI check, and a pull-request reviewer answered different needs. Choosing where the help belonged was part of the design.
One name, three answers
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. The work was to give them a shared foundation while those surfaces kept shipping.
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 could render correctly in its own context while leaving a different answer for the next team to interpret.
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. The design-systems team 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. The naming decisions needed a dependable way to reach the page.
Separate the value from its use
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. Changing it for that component could affect other consumers that had no reason to inherit the same decision.
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 real maintenance concern. Adding a layer means more relationships to name and understand. But keeping one level had pushed those relationships into the places using the tokens, where they were harder to see and change together.
The question was where to carry the complexity. I wanted the structure to name a component’s decision explicitly, so another team could use the primitive without unknowingly adopting that decision too.
The work between teams
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 design team, a standing 1:1 with the staff designer, and a weekly meeting with the two product design teams driving the visual language. We brought design opinions into the engineering work and engineering constraints into the design work. Those conversations helped us see connections between teams and surface problems while they were still cheap.
A lot of what I shipped was guided by people describing how they wanted to work or something that felt broken to them. Their feedback shaped the details of the tools as well as the direction. My product-design experience helped me find the issue underneath the request; my engineering work let me carry the response into the workflow.
Make the system readable
One Tailwind configuration. One component library. One token set.
With two token sets overlapping, a rendering problem could begin in either set or in the way they were loaded together. Before improving individual components, I needed to make those relationships traceable. Consolidating the Tailwind configuration, library, and tokens gave us a dependable place to investigate and correct a shared decision.
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.
Make migration part of the change
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. Automated migration helped us carry the library’s changes into the product instead of leaving each team to work out its own upgrade.
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 features that were unused or used sparingly. I audited the consumers and prepared the changes before removing the syntax. Even when a removal affected no known consumer, I labeled it major so the release would signal that the contract had changed.
The codebase kept moving. Product teams could add uses of an old convention after a codemod had already run, so the migration tools needed to be useful again. We could rerun them against the new work. Migration was part of maintaining the system while people used it.
Give reviewers the reasons
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 flagged a change that deserved attention. The written context explained what was removed, where it was consumed, and how the upgrade had been prepared. That helped reviewers assess the actual risk. A major version couldn’t tell that story on its own.
This added a review cost. I thought that was worth paying while we changed shared foundations: the context made approval easier, and the review gave teams a place to question the change. A codebase that was updated too quickly needs slow thinking.
The change I pulled back
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.
Put the fix beside the work
Documentation didn’t always change the patterns developers were used to writing. I worked on three ways to bring the guidance closer to their work:
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 can suggest the code to write and explain the correction where the engineer is already reviewing 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.
Check the guidance too
Making guidance available to agents gave us two things to examine. Did the guidance change what a model produced? And were the claims in that guidance true?
We built evals for the first question and a CI fixture for the second. A model following an instruction doesn’t establish that the instruction is correct. We needed to check the guidance against the system it described as well as measure its effect on the output.
The checks caught a few claims that needed correcting, 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.
That extended the same responsibility from the code into its guidance. A shared convention needed a way to check its implementation; a recommendation needed evidence behind its claims. The last part of Building Arbor develops that distinction.
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 the team could carry forward
At handoff, token changes could travel from code to Figma and from Figma into a code pull request. Shared branch naming let the pipeline map a code branch to its Figma counterpart. The sync ran in a GitHub Action with linting and a log of what changed and why. It removed the manual copying between those two representations.
The documentation and agent tools drew on the same underlying information. The site, MCP server, and CLI gave people and models different ways to reach it, reducing the separate copies the team had to maintain.
I left Linktree with a Design Systems team that hadn’t existed when I arrived, and a system for carrying shared decisions into the tools where people worked. The handoff included the means to keep changing it.
Write something once and then point at it.
Shared guidance reaches five places decisions get made. People and agents need different interfaces to it, and both need the reasons behind a recommendation.
What I’d start with today
I’d begin earlier with the feature teams, finding out which parts of their surfaces were still in use, which were dead, and which nobody could confidently explain. The technical audit matters, but institutional knowledge is uneven. Someone often knows a reason the code alone can’t tell you.
That is the part of the work I keep returning to: researching the problem, designing a response with the people affected, and implementing it where it can help. A Figma plugin, a migration tool, or an MCP server can each be the right answer. I want to understand the need well enough to choose, then build the solution through to production.