← All posts

What a company-wide UI library actually costs

Gjorge Karakabakov

Every company past a certain size arrives at the same meeting. Three teams have three different buttons. One has a modal that traps focus and two that don’t. Somebody says we should have a shared component library, everyone nods, and a ticket gets written.

The ticket is wrong. Not the idea — the ticket. It describes the work as building components, and building components is the part that takes the least time.

I built one of these at uLesson, across a multi-team engineering org. Here is what the work actually consisted of.

The library is a standards project wearing a component costume

The first pull request I opened was not a component. It was a document: what a component in this library is allowed to assume, what it must expose, and what it may never do.

Three rules did most of the work.

No component owns its own spacing. A Button has internal padding. It does not have a margin-bottom. The moment a component reaches outside its own box, every consumer is fighting it, and every consumer’s fix is a different override. Layout belongs to the layout, not the leaf.

Every interactive component takes a ref and spreads the rest. Somebody will eventually need to attach a tooltip, a form library, an analytics handler, or a focus manager to your input. If they cannot, they will copy the file and change one line, and now the library has a fork it does not know about.

Variants are a closed set, not a style prop. variant="danger" is a decision the design system has made. style={{ background: 'red' }} is a decision that outlived whoever made it. The prop signature is where the standard is actually enforced, and TypeScript is what enforces it — a union of literals, never string.

None of these are novel. What matters is that they were written down before the first twenty components, because retrofitting them across twenty components is a quarter of work nobody has budgeted.

Storybook is documentation, and documentation is the product

The library existed for other teams. If they could not see what was in it, it did not exist.

The thing I would tell anyone starting: document the existing components first, not the new ones. The temptation is to build the shiny new library and write stories as you go. But adoption does not come from the new components — nobody is blocked on those. It comes from a developer on another team finding the date picker they were about to write, already built, already tested, with a story showing the four states they needed.

A story is also a test you can look at. Writing one forces you to instantiate the component with no application context around it, which is where you discover it secretly depends on a provider three levels up.

The coverage floor is a social device

We held unit tests at a minimum of 80% coverage, with Vitest and React Testing Library, plus Cypress for end-to-end flows.

I want to be careful here, because coverage percentages attract more argument than they deserve. 80% is not a quality claim. A component at 100% coverage can still be wrong, and everybody knows it.

What the floor actually does is make the conversation impersonal. Without a number, “this PR needs more tests” is one engineer’s opinion about another engineer’s work, and it gets relitigated every week by people who are tired. With a number, it is a build step. The argument moves from should this be tested — which is a fight — to what is worth testing here, which is a design discussion and a much better use of the same ten minutes.

The rider that matters: measure coverage on the library, not on the apps. Application code has enormous surface that is genuinely not worth unit testing. Shared components are the opposite — every one of them is used in places you cannot see, by people who will not notice the regression until it is in production.

Testing Library, specifically, pushes you toward querying the way a user would. That has a side effect worth having: a component that is hard to test with getByRole is usually a component with an accessibility problem. The test suite catches missing labels and unreachable controls almost as a by-product.

The internal-tools argument

The same effort also went into replacing paid third-party tooling with internal equivalents, and the reasoning is the same reasoning as the component library.

A licensed widget is fine until you need it to behave slightly differently. Then you are writing wrappers around someone else’s abstraction, and each wrapper is a small permanent tax. At some point the wrappers cost more than the thing would have. The judgement call is where that point is — and it is much earlier than most teams assume for anything that sits on the critical path of your product’s core interaction, and much later than engineers want it to be for anything peripheral.

Nobody should build their own payment form. Plenty of teams should build their own editor.

What actually drives adoption

Not the announcement. Not the wiki page.

  • Being first. If a team is about to build a modal this sprint and the library has one today, they will use it. If it lands next sprint, they now have their own and will not migrate.
  • Migration PRs you write yourself. Do not ask a team to adopt the library. Open the pull request that adopts it for them, small enough to review in ten minutes.
  • A visible list of what exists. See Storybook, above.
  • Not breaking them. One bad major version undoes a quarter of trust. The library’s release discipline matters more than its API design.

The honest summary

Roughly: a fifth of the time was writing components. The rest was standards, documentation, tests, migration, code review, and answering questions in Slack.

That ratio is not a failure of planning. It is the project. A component library is infrastructure for how a group of people agree to build things, and the components are just the artefact that agreement produces. Plan the ticket for the real work and it goes fine; plan it for the components and you will be explaining the overrun for two quarters.