Skip to content
Design

Component API design: 6 prop rules that prevent misuse

Four boolean props allow 16 versions of one button. Six rules for component APIs where the wrong use fails to compile, with the TypeScript to copy.

October 30, 2026 · 8 min read

A tall dark wall in the middle with six slots of different shapes, a square, a circle, a pill, a diamond, a tall bar and a wide bar; six white pieces of matching shape pass through and white lines run from them into one large white button on the right, while three dark pieces of the wrong shape stop against the wall on the left

Component API design is the practice of choosing a component's props so that the intended use is the shortest one to write and the wrong use fails before it reaches a screen. The props are where a design decision stops being a picture and becomes a rule: a Figma file can show that a button comes in three variants, and only the type signature can refuse a fourth.

Teams that run one design system across several products meet this problem first. Every prop that accepts a combination the designers never drew is a combination someone will ship. Below are the six rules we apply to props, a worked example on a button, and what a strict API costs.

Why does a loose component API cost so much?

Take a button with four optional booleans: primary, secondary, danger and small. Four independent flags allow 16 combinations. The design file shows a handful of them. The rest are forbidden nowhere the compiler can see, so they reach production through ordinary pull requests: a button that is primary and secondary at once and renders whichever class won the cascade, a destructive action drawn as a quiet link.

No test catches these, because nothing throws. They surface as drift: the same action drawn three ways on three screens. We described how that drift builds up in component drift. The props are where most of it gets in.

Why doesn't documentation fix it?

The usual answer is a usage page that says "do not combine primary and secondary". The page is right and changes little, because developers read the type signature in their editor, not the docs site. When the documentation and the type disagree, the type wins every time someone presses autocomplete. So the rule has to move into the type.

Rule 1: replace competing booleans with one variant prop

When several props describe looks that exclude each other, they are one prop with several values. MUI's API design guide compares the two shapes and lists what the enum buys: it prevents an invalid combination from being used, it bounds the number of props exposed, and it takes new values without new props. The price is a few characters: variant="primary" instead of primary.

The test takes a second. If setting two of the booleans together makes no visual sense, they were never independent. Keep booleans for states that combine with everything else, such as disabled or loading.

Rule 2: make dependent props a discriminated union

Some props only make sense together. A button that navigates needs an href and no click handler. A button that acts needs an onClick and no href. Written as two optional props, "both" and "neither" are legal. Written as a TypeScript discriminated union, each branch states what it requires and what it forbids, and the compiler narrows the type inside the component.

Yaron Minsky of Jane Street put the principle in four words: "make illegal states unrepresentable". For props, the illegal states are the combinations you would otherwise warn about in a code comment.

Rule 3: require what accessibility requires

An icon-only button has no visible text, so it has no accessible name unless the caller supplies one. GitHub's Primer puts that duty on the caller: its IconButton accessibility notes say you must provide a sufficiently descriptive aria-label, which becomes the button's accessible name. Make that prop required in the type and a missing label turns into a compile error on the developer's machine, weeks before an audit would find it. The same goes for an image's alternative text, a dialog's title and a form field's label.

Rule 4: name props by role, never by appearance

color="red" describes a pixel. tone="danger" describes a decision, and it survives the rebrand that turns danger orange. It is the same split we use for design token names: the prop speaks the semantic vocabulary and the tokens behind it hold the values. Name one idea one way across the whole library as well. If one component takes disabled, none should take isEnabled.

Rule 5: close the styling escape hatch, or label it as one

An open style or className prop on every component is a second design system with no rules. Each override it allows stays invisible to the maintainers until they change something underneath it. Two large systems show the two ways out.

  • Adobe's React Spectrum names it. Its escape hatches are called UNSAFE_className and UNSAFE_style, and its styling guide calls overrides to Spectrum styles highly discouraged, pointing teams to build a custom component instead. Every override is one search away.
  • GitHub's Primer removed one. In Primer React v38, announced in October 2025, the sx prop is no longer supported, and teams style components through className and CSS Modules. GitHub also published a temporary @primer/styled-react package for the components that still had sx usage downstream, which shows how much code one open styling prop collects over a few years.

Whichever way you choose, count the uses. A rising number of overrides per release tells you which variant the system is missing.

Rule 6: compose with children instead of adding a prop per case

Props multiply when a component tries to own every layout: iconLeft, iconRight, badge, as, linkComponent. Composition moves that variation into children. Radix's asChild tells a part not to render its default element and to pass its props and behaviour to its child instead, so a menu trigger can render as your router's link without knowing that routers exist. The component keeps its rules (focus, ARIA, keyboard) and hands over only the element.

What does this look like on a real button?

Here is a loose API of the kind that grows one request at a time:

type ButtonProps = {
  primary?: boolean
  secondary?: boolean
  danger?: boolean
  small?: boolean
  href?: string
  onClick?: () => void
  icon?: ReactNode
  style?: CSSProperties
}

Eight optional props, every one of them legal alongside every other. The same component after the six rules:

type ButtonBase = {
  variant: 'primary' | 'secondary' | 'ghost'
  tone?: 'neutral' | 'danger'
  size?: 'small' | 'medium'
  disabled?: boolean
  children: ReactNode
}

type ButtonProps = ButtonBase & (
  | { href: string; onClick?: never }
  | { onClick: () => void; href?: never }
)

type IconButtonProps = Omit<ButtonBase, 'children'> & {
  icon: IconName
  label: string // becomes aria-label, and the tooltip
  onClick: () => void
}

The visual variants are one prop. Danger is a tone, so it combines with any variant the designers approved. A link and an action are two branches, and passing both is a type error. The icon-only case is its own component with a required label. The style prop is gone: a look the system lacks becomes a request for a new variant, which is the conversation the system exists to have.

What does a strict API cost?

Three things, and they are worth stating before a team signs up.

  • Edge cases slow down. A product team that needs a look the system does not offer waits for a new variant or writes its own component. That wait is the point, and it needs a short path: a contribution process that answers in days keeps teams inside the system. When the answer takes a quarter, teams leave, which is one of the causes we list in why design system adoption stalls.
  • Types protect TypeScript callers only. A consumer in plain JavaScript gets none of the compile errors. Pair the types with development-only runtime warnings for the combinations that matter most.
  • Removing a prop is a breaking change. Tightening an existing API means a major version and, at scale, a codemod. Primer's v38 migration is a public example of the size of that job. Strict from the first release costs far less than strict later.

On our own site a check in the build refuses inline styles, and any one-off class has to live in a single prefixed stylesheet with a note naming the task that moves it into the system. The number of one-offs is printed on every run. It is the same idea as Rule 5 applied to a whole codebase: the escape hatch exists, it has a name, and someone counts it. A smaller API is also easier to keep small, which is the argument we make in the design system diet.

Sources

Frequently asked questions

Related articles

Studio

Start a project.

We write about what we build. Tell us what you want to build.