A Component's Props Are a User Interface
And the person using that interface is an engineer
A component is a promise made in public. Its props are the words of that promise, and the people reading them are engineers who will call your component a thousand times without ever reading its source. So a prop is not a convenience you offer. It is a decision you push onto every future caller, forever. The hard part of building a component library is not the CSS. It is deciding what the component will refuse to do.

I have shipped components I was proud of that turned out to be quietly terrible, because they were too helpful. They accepted anything. They had a prop for every request that ever landed in the queue. And the product built from them looked like six different teams had never met, because that is exactly what happened. Every prop I added was permission to be inconsistent, and people took the permission.
The boolean trap
Here is the most common way a component API rots. You start with a button. Someone needs a prominent one, so you add isPrimary. Then a big one, so you add isLarge. Then a destructive one, so you add isDanger. Each looks reasonable on its own. Together they are a trap. Three booleans give you eight combinations, and most of them are nonsense. What is a large primary danger button that is also disabled and loading? Nobody decided. The type system happily allows it, so someone will ship it.
- isPrimary, isSecondary, isDanger as separate flags
- Eight states from three booleans, most invalid
- isPrimary and isDanger both true: undefined
- Every caller can invent a new look
- The valid set lives only in your head
- variant: primary, secondary, danger
- Three states, all of them real
- Contradictions cannot be typed at all
- Callers pick from a menu you wrote
- The valid set is the type itself
This is the boolean trap, and the cure is a union. Replace the pile of flags with a single variant prop whose type lists the only looks you support. Now a contradiction is not a bug you catch at review. It is a state that cannot be written down. The compiler refuses it before the code runs. That shift, from checking for bad states to making them unrepresentable, is the most useful move in component API design.
// The trap: three booleans, eight combinations, most invalid
type ButtonProps = {
isPrimary?: boolean
isSecondary?: boolean
isDanger?: boolean
isLarge?: boolean
}
// The fix: closed sets. Contradictions cannot be typed.
type ButtonProps2 = {
variant?: "primary" | "secondary" | "danger"
size?: "sm" | "md" | "lg"
}Real design systems make this exact choice. Shopify Polaris gives its button a tone and a variant, not a drawer of booleans. Atlassian does the same with an appearance prop. The reason is not taste. It is that a named set of variants is the contract. It tells the caller what the system supports, and just as loudly, what it does not. A boolean tells the caller nothing except that you ran out of names.
When one prop cannot hold the behaviour
Some components are simple enough that props are the whole story. A button is close. But watch what happens to a Select. It starts innocent: an options array, a value, an onChange. Then the requests arrive. Some options need icons. Some need to be grouped under headings. Some are disabled. One team wants a divider. Another wants a custom row with a description under the label. Your options prop grows a type so baroque that nobody can read it, and every new request means editing the component itself.
This is the signal to stop passing data and start passing structure. Instead of describing every option as an object in an array, you let the caller compose the pieces. Select becomes a small family: Select, Select.Trigger, Select.Option, Select.Group. The behaviour, which key opens the list, how focus moves, how typeahead works, lives in the parent and is shared through context. The appearance of a single row lives where the caller writes it. This is the compound component pattern, and Radix, React Aria and Headless UI all ship their menus and selects this way.
// Config prop: every new need edits the component
<Select options={[
{ label: "Draft", value: "draft" },
{ label: "Published", value: "pub", icon: "check" },
]} />
// Compound: the caller composes, the parent owns behaviour
<Select value={status} onValueChange={setStatus}>
<Select.Trigger />
<Select.Group label="States">
<Select.Option value="draft">Draft</Select.Option>
<Select.Option value="pub">
<Check /> Published <Hint>visible to all</Hint>
</Select.Option>
</Select.Group>
</Select>The trade is real, so name it. Compound components are more to learn than one array prop. The caller has to know the pieces fit together, and a bad composition can be assembled that a single prop would have prevented. The gain is that the component stops being a bottleneck. New requests become new arrangements of existing parts, not new props on a struct that only you can safely change. The rule I use: reach for compound components the moment the behaviour outgrows the data, not before.
Headless, and what you give up for it
There is a pattern one level further out. Ship the behaviour and the accessibility with no appearance at all. This is the headless component: a hook or an unstyled primitive that manages state, keyboard interaction, focus and ARIA, and hands you the wiring to attach your own markup. React Aria from Adobe, Radix Primitives and Headless UI are the well known examples. You get a combobox that follows the WAI-ARIA authoring practices out of the box, and you paint it however your brand demands.
I reach for headless when two things are true at once: the behaviour is genuinely hard to get right, and the appearance genuinely varies. An accessible combobox, a modal that traps focus correctly, a date picker that handles time zones and locales. Getting the accessibility right is months of work and a specialist's knowledge. Getting the look right is a Tuesday. Headless splits the two so you buy the hard part and keep control of the easy part.
| Approach | You control | You inherit | Real cost |
|---|---|---|---|
| Styled library component | Almost nothing visual | Look and behaviour | Fights your brand; overrides pile up |
| Headless primitive | All of the appearance | Behaviour and accessibility | You must build and maintain the styling layer |
| Fully hand rolled | Everything | Nothing | You will get the accessibility wrong |
What you give up is worth saying plainly. A headless primitive hands you responsibility for every visual state, and there are more of them than you think: hover, focus visible, disabled, invalid, loading, selected, the reduced-motion version. A styled library gave you those for free. So headless is not the sophisticated default. It is the right call for a small number of hard components and the wrong call for the fifty simple ones, where a styled, opinionated component is what keeps the product consistent.
Controlled, uncontrolled, and the className fight
Two smaller decisions carry more weight than they look. The first is controlled versus uncontrolled. A controlled input takes its value as a prop and reports every change to the parent. An uncontrolled one keeps its own value inside and only tells you when you ask. Callers want both. They want to drop in a search box with zero wiring, and they want to fully drive a form field from outside. Supporting both in one component is harder than it looks, because you have to detect which mode you are in and never mix them.
| Uncontrolled | Controlled | |
|---|---|---|
| Who owns the value | The component, internally | The parent, via a prop |
| Setup cost for the caller | Almost none | Wire value and onChange |
| Good for | Simple forms, quick drop-in | Validation, linked fields, undo |
| The trap | Parent cannot read or reset it | Forget onChange and it looks frozen |
The second small decision starts fights: should your component forward className and style to its root node? Blocking it feels principled. You are protecting the design system from arbitrary overrides. But watch what teams actually do when you block it. They do not accept your constraint. They copy your component into their own folder, tweak the one thing they needed, and now you have a fork you do not control and cannot update. The escape hatch you refused to build got built anyway, worse, and out of your sight.
So I lean toward passing className through and offering children as an escape hatch, then watching what people reach for. An override that shows up in twenty places is not a violation. It is a feature request the system has not answered yet. The passthrough is how you find out. The alternative, a locked component, does not stop the override. It just hides it in a fork where you will never learn from it.
Against maximum flexibility
The common instinct is that a good component is a flexible one. Add the prop. Support the case. Say yes. I used to believe this, and it produced the worst systems I have worked in. A component that can do anything teaches nothing. Faced with forty props, two engineers will make two different buttons, both technically valid, and the product will show it. Flexibility does not create consistency. It defers every decision to the least informed moment, which is a caller under deadline who just wants the thing to render.
Simple things should be simple, complex things should be possible.
Kay's line is the whole target. The default path should be short and hard to get wrong, and the hard cases should be reachable through a clearly marked door, not through forty knobs on the front. A component with forty props has failed at both. The simple thing is not simple, because you must first understand forty options to know which four you need. And the complex thing is not really possible either, because prop number forty-one is always missing. When I see a component with forty props, I no longer read it as powerful. I read it as two components that were never separated, an honest split waiting to happen.
The split is usually obvious once you look. A Table with forty props is a simple Table plus a DataGrid, and pretending they are one component makes both worse. The simple case pays for the complex one on every line of every caller. Cutting them apart lets the Table stay a five-prop component that a newcomer understands in a minute, while the DataGrid carries the weight for the teams that truly need it. Two clear components beat one that does everything, every single time.
None of this is static. Props accumulate, needs change, and a component you got right in year one is wrong by year three. So the last piece of API design is how you change it without breaking every caller. Treat a public component like a public API, because that is what it is. Deprecate a prop before you delete it, keep the old behaviour working for a release, and ship a codemod, a small script that rewrites callers automatically, so upgrading is a command and not a migration project. A breaking change without a codemod is a tax you levy on every team downstream.