Skip to main content
design systems

I Like Boring Buttons: Why Component APIs Should Be Predictable

The best component API is the one you can guess without reading the docs. Predictability is a feature, and unpredictability is a bug.


6 min read

A predictable component API means a developer can write <Button size="sm" variant="ghost" /> and correctly guess that <Badge size="sm" variant="ghost" /> works the same way. That's it. That's the whole goal. When your design system achieves that, it stops being documentation you have to read and becomes a language you can speak. The cost of not achieving it is that every component becomes its own puzzle, and "go read the Storybook" becomes a tax on every feature shipped.


The Naming Convention Problem

Pick any three popular component libraries and compare how they handle size:

  • MUI uses size="small" / size="medium" / size="large"
  • Radix UI delegates sizing to your own CSS or className
  • shadcn/ui inherits from whatever variant schema you configure in cva()

None of these is wrong. All of them become wrong the moment you mix conventions inside a single library.

I have worked in codebases where Button took size="sm" and Avatar took sz="small" and Input took inputSize="medium". These were not typos — they accumulated over time as different engineers added components. Each decision made sense in isolation. Together, they meant that every component required a trip to the docs regardless of how much experience you had with the system.

The fix is not clever — it is boring and deliberate: write a prop contract and enforce it everywhere.

// types/component-variants.ts
export type Size = "xs" | "sm" | "md" | "lg" | "xl";
export type Variant = "solid" | "outline" | "ghost" | "link";
export type ColorScheme = "neutral" | "brand" | "destructive" | "success";

// Every component that has a size uses Size.
// Every component that has a visual treatment uses Variant.
// No exceptions.

When a new engineer writes a Tag component and reaches for size, they find Size. When they reach for variant, they find Variant. The type system becomes the documentation.

How a Button API Should Predict a Badge API

The real test of API consistency is cross-component prediction. If your Button looks like this:

<Button size="sm" variant="outline" colorScheme="brand" disabled>
  Submit
</Button>

Then a developer encountering Badge for the first time should be able to write:

<Badge size="sm" variant="outline" colorScheme="brand">
  New
</Badge>

Without checking anything. If they have to check — if Badge uses intent instead of colorScheme, or style instead of variant — you have already failed the API contract.

This is not about being strict for the sake of strictness. It is about cognitive load. Every naming inconsistency is a small friction that compounds. In a system with 30 components, 30 small frictions become a significant drag on every developer who touches the codebase.

Radix UI gets this right by not getting in the way. Because Radix primitives are unstyled and composable, teams building on top of them are forced to make their own naming decisions — and the best teams make those decisions once and apply them everywhere. shadcn/ui goes further by giving you a starting point that is internally consistent out of the box.

The Cost of Inconsistency

Here is what inconsistency actually costs:

Time to onboard. A developer joining the team cannot guess anything. They have to read documentation or ask questions for every component they touch.

Time to review. Code review becomes "why did you use type here and variant there" instead of reviewing actual logic.

Refactoring drag. When you want to rename a prop across the system, inconsistency means you cannot do it reliably. You end up with a migration guide that nobody reads and a codebase split between old and new conventions.

Distrust. Developers stop trusting the system and start reaching for one-off solutions. The design system becomes a suggestion instead of a contract.

Composition Over Configuration

The "one more prop" problem is the accumulation of configuration that should instead be handled through composition.

Take a common example: you have a Button and now product wants a loading state. The tempting move is:

// The path to prop explosion
<Button loading={true} loadingText="Saving..." spinnerPosition="left" />

Now you own the loading state. You own the spinner. You own the text. Every edge case becomes your problem. Compare to the compositional approach:

// You own the button. The caller owns the loading state.
<Button disabled={isSaving} aria-busy={isSaving}>
  {isSaving ? <Spinner size="sm" /> : null}
  {isSaving ? "Saving..." : "Save"}
</Button>

This is more verbose at the call site, but it is infinitely more flexible and requires no changes to the Button API. The caller has full control over how loading is represented.

The as prop pattern extends this to polymorphism:

// Button renders an anchor when as="a"
<Button as="a" href="/dashboard" variant="ghost">
  Go to Dashboard
</Button>

Radix UI's asChild prop takes this further — instead of passing a string, you pass the actual component you want to render:

<Button asChild>
  <Link href="/dashboard">Go to Dashboard</Link>
</Button>

This means Button never needs to know about your router. It composes. The API stays small.

Why "One More Prop" Is Always a Design Decision

Every prop you add to a component is a permanent API commitment. Once developers are using <Button spinnerPosition="left" /> in production, you own that prop. Forever, or until you write a migration guide that causes pain.

The discipline is to ask, before adding any prop: is this configuration that belongs inside the component, or is it content that belongs to the caller?

  • disabled belongs inside — it changes behavior the component controls.
  • loadingText belongs to the caller — it is content.
  • size belongs inside — it is a design system constraint.
  • iconBefore is borderline — it is content, but it has layout implications.

When you are unsure, lean toward composition. A component that renders {children} can have any content passed to it. A component with 12 props for content variations is a component that has stopped being a component and become a template engine.

Predictability as a Feature

The boring button is the good button. Not because it is simple — because it is predictable. When I open a codebase that uses a well-designed system, I can write components I have never written before and be right on the first try. That is not luck. That is a system that was built with discipline.

The rule is not complicated: pick one name for each concept, apply it everywhere, and treat deviation as a bug. size not sz. variant not type. colorScheme not intent. Document the contract in types, enforce it in code review, and never compromise it for the sake of one component that "just needs something slightly different."

When that temptation arrives — and it will — the correct answer is to reconsider the design, not expand the API.