Drew Poling

Components are contracts

(15d ago)

A lot of component libraries are really collections. You get a button, an input, and a card, each designed, documented, and tested on its own. They look coherent side by side in a gallery. In a real app, where components get combined, share state, and end up in layouts nobody planned for, the gaps show up quickly.

The part I find most interesting is how libraries mix up two things: what a component looks like and what it means. A Button is both a styling choice and a role. When you use one, you are making both decisions at once, usually without noticing. So product decisions at the top of an app end up tied to CSS decisions at the bottom, and changing one risks breaking the other.

The meaning layer, the part that says "this is a primary action" or "this is destructive" or "this navigates somewhere," is rarely modeled directly. It lives in the heads of the people who built the system and shows up indirectly through prop names, variant strings, and docs that describe appearance rather than intent. A new engineer has to piece that model together by reading styles.

The experiment

I wanted to see whether this mattered in practice, so I ran a small experiment. I took a standard shadcn/ui setup (Radix primitives styled with Tailwind) and built the same dashboard feature twice: once against those components directly, and once against a small semantic component library I built on top of them. The semantic library had no styling props. Every component described a role, not an appearance.

I had Claude write the code in both cases, the way you would when shipping UI with a model in the loop. I ran the same prompt several times against each setup so I could compare results across runs instead of judging from a single generation.

With shadcn/ui

Asked to build a dashboard action panel, the model produced valid, working JSX right away. It also made a lot of design decisions on its own, picking whichever variant patterns were most common in its training data rather than what fit the system.

// LLM output against shadcn/ui: valid, but the styling choices are guesses
<Button variant="outline" size="sm" className="text-red-600 border-red-300">
  Delete record
</Button>

<Button variant="default" className="bg-blue-600 text-white">
  Save changes
</Button>

<Button variant="ghost" size="sm">
  Cancel
</Button>

The output works, but there is no design system behind it. It is the model's best guess at what these buttons usually look like.

With the semantic library

With the same prompt against the semantic library, the model stopped making decisions it had no basis for.

// LLM output against the semantic library: role expressed, styling handled elsewhere
<Action.Destructive>Delete record</Action.Destructive>
<Action.Primary>Save changes</Action.Primary>
<Action.Cancel>Cancel</Action.Cancel>

The model did not need to know what a destructive action looks like. It only needed to know that deleting a record is destructive. The styling is defined once, in the library, and is not the model's job.

Prompt overhead

The difference in prompt overhead added up quickly. With shadcn/ui, getting output that matched the design meant spelling out the rules in the prompt every time: which variant maps to which role, what the color conventions are, and when to use outline versus ghost versus default. With the semantic layer, those rules were already in the components, so the prompt could focus on the feature itself. I also needed fewer rounds of corrections per feature, and the results were more consistent from run to run.

This was a small, informal test, so I would treat it as a direction worth exploring rather than a measurement.

What the token count shows

Later I measured one piece of this properly. I wrote the same hero section twice, with a heading, a lead paragraph, two buttons, an address input, and an error message. One version used raw Tailwind and the other used intent components, and I ran both through a real tokenizer instead of estimating from character counts. The Tailwind version came to 305 tokens and the intent version to 195, about 36 percent fewer, and that includes the import line the intent version needs.

The savings are real, but they are not the main point. The Tailwind version contains six class strings, and each one is a set of decisions about color, spacing, and type that a model can get slightly wrong. The intent version contains six role names. Fewer tokens is a side effect of the model having fewer things to decide.

What a variant prop actually tells you

A component that expresses "primary action" through variant="primary" is still mostly describing appearance, just with a name that sounds like meaning. Whoever uses it still has to know what primary implies visually and when it is appropriate. That knowledge usually lives in convention rather than in the system.

The approach I am exploring separates the two completely. A component expresses its role, and how that role is styled is decided somewhere else. When the visual design changes, the role layer should not have to change with it, and someone building a feature should be able to work at that layer without knowing every detail underneath.

Most libraries do not get there, because the easy path is to keep adding variants until the list of props becomes its own undocumented language.

I ended up doing a version of that myself, just with meaning instead of appearance.

Too much meaning is its own problem

As my own library grew, it reached 84 intents across 16 namespaces, including 12 for actions alone: submit, confirm, save, create, cancel, close, delete, edit, and a few more. When I looked at what they actually resolved to, those 12 actions only used 7 distinct styles. Submit, confirm, save, and create all rendered the exact same primary button.

That looked like precision, but it only moved the guessing. Instead of picking a color, the model had to pick between four synonyms that produced identical output, and nothing in the system said which one was right. Two engineers would make different choices too. Someone reading action.save next to action.confirm could reasonably assume the difference mattered. It did not.

The next version started from a much smaller vocabulary: about two dozen intents across six domains, with five for actions (primary, secondary, ghost, danger, and navigate). The naming format is domain.meaning, and a third segment is only allowed when the difference is structural, like a multiline entry that needs a textarea instead of an input. The test for a new intent is whether it makes sense on more than one component. If it only ever applies to one, it is a component alias, not a meaning.

Some of those names, ghost in particular, lean closer to appearance than meaning. I kept them because they match decisions people actually make, and a vocabulary nobody can remember is worse than one that is slightly impure.

Where the line goes

Separating meaning from styling raises the question of what counts as styling. Where I landed is that the intent owns visual identity: color, weight, type scale, emphasis, and radius. A className is still allowed, but only for instance layout like margin, max width, and positioning. A heading can stay narrow on one page without that becoming a new kind of heading.

The rule that makes this hold up is that a className should never fight what the intent already set. If a call site needs to override an intent's color, the intent is wrong, and the fix belongs in the vocabulary rather than at the call site. That turns overrides into a signal instead of a habit.

I also took out escape hatches I had added early. The first cut of the smaller library had direction and gap props on its stack component, and on the first day I replaced them with layout intents. Every escape hatch is a place where the model goes back to making styling decisions, which is the thing I was trying to take away.

Writing the meaning down

The meaning layer only helps if whoever is writing the code can see it. In the smaller library, the spec a model reads is generated from the registry itself. It holds the naming grammar, the className rule, and a table of every intent and what it resolves to. Regenerating it is part of changing the registry, so the description cannot drift away from the code.

The spec also says what to do when nothing fits: add one entry to the registry, following the same grammar, instead of writing a className blob at the call site. That second half is easy to skip. A system that only says no to styling decisions leaves the model stuck the first time it needs something new, and it falls back to exactly the kind of output the shadcn/ui version started with.

Where this leaves me

I am still working out where this layer should live and who should own it. Most tooling does not model it, and frameworks do not enforce it. A few rules have held up so far: name roles rather than looks, keep the vocabulary small enough to remember, let className handle layout and nothing else, and generate the spec from the code.

The gap between what a designer means by "primary action" and what an engineer ships as variant="primary" tends to stay invisible until a system is big enough that changing either one is risky. That is the best argument I have for modeling it early, while the vocabulary is still small enough to get right.