In production · 2026
One interface system shared across every application I build
A private package with the primitives and tokens the portfolio, Rebote and future CRMs all consume, so they stop drifting apart.
- Role
- Design and development
- Primitives
- 29
- Design tokens
- 108
- Consuming applications
- 2
Problem
The interface components lived inside the portfolio. With a second application on the way, the only way to reuse them was to copy them — and two copies of the same button stop being the same button the moment someone fixes one of them.
This is not about looks. It is that every fix has to be made N times, and you have to remember all N.
Decision
A private, published, versioned package rather than pulling every application into one repository. The monorepo alternative avoids publishing and allows atomic changes, but it requires every project to live in the same repo, and not all of them can.
The rule that decides what goes in: if a component needs an app-specific prop to work, it is not ready to be shared. It stays where it is until a second project asks for the same thing. Without that rule, the library fills up with components carrying fifteen props to cover three cases.
Implementation
Twenty-nine primitives and a hundred and eight design tokens. The tokens are what really makes two products match: components can change, but if colour, radius and typography line up, two different applications still look like they came from the same place.
It ships as TypeScript source, uncompiled, and the consumer transpiles it. That way there are no two build steps to keep in sync, and no artefact that can fall behind the code.
The client-component directive goes only where it is needed: ten of the twenty-nine remain server components. Marking them all as client would have been quicker and would have pushed JavaScript into every page that uses them.
The registry read token reaches the containers as a build secret, not an argument: an argument is baked into the image layers and readable by anyone who pulls it.
Result
The second application is Rebote, already in production with its styles written by hand. Migrating it meant rewriting a single file: an adapter layer that 26 of its 28 screens already imported from. None of those screens was touched.
That file stays instead of replacing every call with the library component, for two concrete reasons. One, the field class is applied to 128 controls, and a good share of them are select and textarea elements, which cannot be a text input. Two, the player avatar, the stat card and the page header talk about players and fixtures: a component that needs to know what a player is does not belong in a shared library. The generic moves up, the domain-aware stays put.
The migration also exposed a design flaw. The barrel exported all twenty-nine primitives, including the ones that depend on animation, form or toast packages; importing a button forced you to install six packages you never use. The barrel now carries only what drags no optional dependencies, and the rest is imported by path. A barrel that makes you install dependencies for things you do not use moves a library problem onto every one of its consumers.