The component library nobody adopted

A polished catalogue failed inside my own projects because it optimized the destination while ignoring every path from existing code.

I built a component library for five applications and then avoided using it in all five.

The library looked convincing in isolation. Buttons aligned. Fields shared a rhythm. Dialogs faded over the same backdrop. A catalogue displayed normal, disabled, loading, and error examples on a quiet white canvas. The package installed cleanly and had an orderly API.

Meanwhile, C62Y, T04P, X8B6, M31V, and R7K1 kept their old components. When I opened one of those repositories to make an actual change, adopting the new library was always one task too many. The existing button had a peculiar event contract. The form field was tied to local validation. The dialog assumed a different focus behavior. The new component was cleaner, but changing to it made the feature under repair harder to predict.

There was no reluctant organization to blame. I was the library author, the application maintainer, and the person repeatedly deciding not to migrate.

That failure removed the comforting explanations. Documentation, announcement, and technical quality were not enough. I had designed an attractive destination and no route from where the applications already stood.

The catalogue answered the easiest question

The first catalogue answered, “What should a new button look like?” It showed several visual variants and a prop table:

kind: primary | secondary | danger
size: small | medium | large
disabled: boolean
loading: boolean

That was useful for a blank screen. None of my applications was blank.

X8B6 had buttons rendered as links when an action opened a separate record. M31V had an asynchronous publish control that could be queued, rejected, or partially complete. R7K1 used controls inside a status stream where repeated clicks could duplicate work. C62Y still contained anchors styled as buttons because its earliest interactions predated the library by years.

The catalogue flattened those histories into visual states. It did not explain which underlying element to use, whether a loading control remained focusable, how repeated activation was prevented, what happened when an action failed, or whether the component owned navigation.

Its cleanest examples were clean because every difficult decision had been removed from the frame.

I tried adding more examples. The page grew, but the adoption problem did not shrink. A catalogue can display a destination. It cannot, by itself, explain how one existing behavior becomes another without breaking what surrounds it.

The library began with a visual inventory made from screenshots. I grouped things that looked alike, then turned each group into a component.

That method caught accidental differences in color, spacing, radius, and typography. It also grouped controls whose responsibilities were different.

Two blue rectangles might represent:

  • A form submission that can fail validation.
  • A link to a different document.
  • A command whose effect is safely repeatable.
  • A command whose effect must occur at most once.
  • A disclosure that changes only local visibility.
  • A queued operation whose outcome arrives later.

Giving all of them the same component without naming those distinctions made the API ambiguous. The component either accepted enough flags to reproduce every behavior or quietly assumed one of them.

I chose flags. Button acquired href, submit, pending, confirm, newWindow, and preventDoubleClick. Several combinations made no sense. Runtime conditionals decided whether it rendered an anchor or button and which events won. The catalogue still looked consistent while the contract became less coherent.

Visual sameness had encouraged behavioral centralization where only styling was shared. The component was not reusable; it was a switchboard for unrelated meanings.

Greenfield APIs hide the migration cost

The library's forms were designed around controlled React inputs with a particular value-and-change contract. T04P's filters used native form submission for a durable URL. X8B6's transaction editor kept a local draft and validated on blur. M31V had server errors that belonged to specific revisions rather than current field values.

The new TextField expected:

<TextField
  label="Title"
  value={title}
  onChange={setTitle}
  error={titleError}
/>

The older applications passed events, read values from form elements, or associated errors through different structures. Replacing one field meant adapting state, validation timing, labels, tests, and sometimes the form submission model.

I had estimated adoption by counting tags:

replace 18 old inputs with TextField

The real unit was a behavior transition:

current value ownership
+ validation timing
+ error association
+ focus behavior
+ submission contract
+ existing tests
-> new field contract

The work was not necessarily unjustified. It was simply absent from the library plan. A greenfield example made migration look like renaming an import because it had no previous behavior to preserve.

The first attempted migration made the form worse

I chose a small settings form in M31V as the first consumer. It contained four fields, one checkbox, and a save button. The page seemed too ordinary to surprise me.

The visual replacement took an afternoon. The new fields looked better and the CSS shrank. Then keyboard testing exposed several regressions.

The old form focused the first invalid field after a rejected submission. The new component rendered error text but did not coordinate focus. The old checkbox used a visible native control; my polished version substituted a custom mark and lost a clear focus outline under one theme. The save button changed its label to “Saving…” and became disabled, which removed it from normal focus behavior while the user's position was still there. A server error appeared in a notification but was not connected to the field that caused it.

Every individual component matched its catalogue example. The form as a task had become less understandable.

I restored the old implementation and wrote down the failure. The library had optimized isolated appearance and left interaction composition to each consumer. Migration uncovered that missing contract, but I had no place to record it except application-specific code.

The failed afternoon was more valuable than another month of components. It moved the work from visual consolidation to behavioral inventory.

Copying props preserved the old mistakes

My next approach was compatibility through permissiveness. If the new component accepted every old prop shape, migration could become mechanical.

For buttons, I added aliases for busy and loading, theme and kind, onTap and onClick. Fields accepted both error strings and error objects. Dialogs allowed either an open boolean or an imperative show() method.

This made the first import changes easier. It also made the library responsible for translating several incompatible contracts forever.

The aliases lacked an expiry. New code copied whichever form appeared nearby. Tests covered old and new branches. Documentation needed to explain combinations I did not want anyone to use. The component could no longer communicate a preferred model because almost every historical model was valid input.

Compatibility is useful when it forms a bridge with two visible ends. Mine was a widening river delta.

I removed most aliases and changed the migration rule. A wrapper could live beside an application, translate one known old contract, and carry a removal condition. The shared component would keep one contract. When several applications revealed the same legitimate need, that need could enter the core deliberately.

This kept historical accommodation close to the history that required it.

A component had no authority over its context

The catalogue gave every example ideal surrounding space. Real pages did not.

The button component included right margin because most catalogue rows contained several buttons. In a real toolbar, the toolbar already owned spacing, so gaps doubled. The field included a bottom margin that collided with grid and list layouts. The dialog set a fixed width that worked for a confirmation sentence and failed for a table. A card component assigned heading size without knowing the page's heading structure.

These were not minor CSS mistakes. They were ownership mistakes.

We needed a rule:

component owns internal composition
parent owns placement among siblings
document owns semantic hierarchy

A button could own the relation between its icon, label, and progress indicator. A button group owned spacing between buttons. The page decided whether the group sat beside a heading or below a form. A card could provide visual containment but could not choose whether its title was an h2 or h4 without context.

Removing external margins made catalogue examples slightly more verbose because examples needed layout wrappers. That verbosity was honest. It showed that reusable components do not float in a neutral canvas when an application uses them.

I considered a component complete when it had visual variants, basic tests, and a catalogue page. That definition rewarded breadth. Adding a badge was easier than resolving dialog focus or form error timing.

The library grew where work was cheap, not where reuse was valuable.

I replaced the completion checklist with a contract:

  • What semantic element or role represents the component?
  • How is its accessible name established?
  • What keyboard behavior belongs to it?
  • Which state does it own, and which state must the consumer own?
  • What happens while an asynchronous action is pending?
  • How do errors become associated and discoverable?
  • Which dimensions and content variations stress the layout?
  • What is the migration path from each known implementation?
  • Which tests detect contract changes?
  • Who—in this case, usually future me—maintains it?

Many catalogue entries no longer qualified as components. Some became CSS recipes. Some became application-local patterns. A few needed much deeper work.

The reduced library looked less impressive in a screenshot. It was closer to something I could trust.

Adoption needed an inventory of consumers

The package could not plan migration without knowing where its candidate patterns lived. I created a simple inventory across the five projects.

For each apparent component, I recorded:

project and route
current implementation
user-visible behavior
accessibility behavior
state ownership
known tests
likely shared contract
migration risk

The inventory immediately challenged the catalogue taxonomy. Four implementations called Modal represented a confirmation dialog, a non-modal side panel, an image lightbox, and a full-screen editor. Their backdrops looked related; their focus and dismissal rules were not.

Several different-looking status indicators shared a stronger contract than the modals. Each needed to represent pending, succeeded, failed, and outcome-unknown operations without implying that a timeout meant failure. That was a meaningful reusable state model even though the presentations differed.

The consumer inventory changed what we centralized. It also gave migration a finite scope. I could point to six existing uses, identify the risky two, and know when the old path was actually gone.

A library without a consumer inventory can only count what it offers. It cannot count the decisions it has changed.

The package version was not the adoption state

All five projects could install the latest package and still render every old component. I had initially treated dependency version as evidence of progress because it was easy to inspect.

The useful state was per contract:

button action semantics: 3 of 7 routes migrated
field error association: 1 of 4 forms migrated
dialog focus contract: 0 of 3 eligible dialogs migrated
shared status model: experimental in R7K1

This looked less satisfying than “all projects use version 1.2.” It reflected reality.

We added development warnings to application wrappers when an old path ran, but only where warnings were actionable and could be removed. A small migration document listed the next eligible use rather than declaring a general mandate. When touching a route, I could choose whether its behavior was understood well enough to move.

The process was intentionally incremental. A flag-day migration across personal projects would be easier to schedule than one across an organization, but it would still combine unrelated risk and erase the chance to learn from each consumer.

One route moving safely taught the system more than a repository-wide replacement that passed compilation.

Documentation had to include the reason for refusal

The catalogue originally showed only supported examples. It did not explain why some requests were rejected.

Once migration began, negative guidance became essential. The button documentation said it would not render as a link; navigation used the link primitive styled through a shared visual recipe. The dialog documentation refused to support non-modal side panels. The field contract did not own server submission or select validation timing on behalf of the form.

These refusals protected meaning. They also helped future decisions. If a new case appeared, I could compare it with an explicit boundary rather than adding another boolean to make the example work.

I wrote short decision records for contentious edges:

Decision: external spacing belongs to composition wrappers.
Reason: consumers use different layout contexts; component margins collide.
Revisit if: a semantic group owns a stable relationship among children.

Documentation became a record of maintained decisions, not merely a rendering of prop types.

The most useful page in the library was no longer the overview grid. It was often the migration note that said, “This old thing is not the same pattern, and here is why.”

I returned to the M31V settings form with a different sequence.

First, I documented the current task: labels, tab order, validation timing, server errors, focus after rejection, pending save, and success confirmation. I fixed two existing accessibility problems before introducing shared code so the migration did not have to preserve mistakes.

Next, I adopted only the label, description, input, and error relationship as a field contract. An application-local adapter translated M31V's event shape to the new value shape. The form continued to own submission and focus after failure. A group component owned spacing; fields owned no external margin.

The save control used a button primitive, but M31V retained the operation state machine. The button presented the current state; it did not decide whether retry was safe.

Finally, I ran the old and new versions against the same keyboard, zoom, server-error, and slow-save scenarios. Visual snapshots caught layout drift. Task checks caught the behaviors that pixels could not.

The migration took longer than swapping tags and less time than repairing the first attempt. More importantly, the next form could reuse both the component and the migration knowledge.

After the reset, adoption was not dramatic. That was a good sign.

One form moved. Then an R7K1 control group used the same spacing and focus rules. C62Y adopted semantic color roles without taking React components. T04P reused a status contract while keeping its table-specific presentation. The library entered each project at the layer that solved a real recurring problem.

Some catalogue components never gained a consumer and were deleted. Their absence did not reduce the system's value. A component nobody uses is not evidence of future consistency; it is an API waiting to become a constraint.

I renamed the experiment J05N and stopped treating the catalogue as its center. J05N would include components, but its purpose became the controlled movement from several local decisions toward a maintained shared contract.

That required inventories, adapters, test scenarios, deprecation paths, and the willingness to keep behavior local when its similarity was only visual.

The failure changed what I meant by “system”

The first library had all the visible artifacts associated with a design system: colors, spacing, components, documentation, and an appealing grid of examples. It failed the least visible requirement. Existing software could not absorb its decisions at a reasonable, explainable cost.

I had assumed adoption would follow quality. In my own projects, quality included adoption.

A reusable component needs a coherent behavioral contract. A shared package needs dependency boundaries. A maintained pattern needs consumers, migration evidence, and a way to remove what it replaces. A catalogue can explain those things, but it cannot substitute for them.

The component library nobody adopted was not wasted work. It exposed the difference between making a new thing and changing an existing system. That distinction would shape J05N more than any token name or React API.

The library became useful only when I stopped asking how many components it contained and started asking a harder question:

What old decision can this new decision safely replace, and how will I know the replacement is complete?