Component API Design: How to Build Components People Love Using

AJ
15 min read

Here's a truth that took me a while to learn: the quality of a React component isn't just about what it does. It's about how easy it is for other developers to use it. You could build the most beautiful, feature-rich button component in the world. But if its props are confusing, if the naming is inconsistent, if it requires a 20-line setup just to render, nobody will want to use it. They'll build their own instead. Good component API design is what separates a design system people love from one they tolerate. And it's a skill that will make you a better frontend developer no matter what UI framework you work with.

I've spent years building UI components for design systems, open-source libraries, and production applications. Along the way, I've made every mistake in the book. Components with 20 required props. Inconsistent naming that confused everyone, including me. Rigid structures that broke the moment someone needed something slightly different. Each mistake taught me a principle, and in this post I'm going to share seven rules that have fundamentally changed how I think about component API design in React and Next.js projects.

Your API Is Your UX

When designers talk about UX, they mean the experience of end users clicking buttons and filling out forms. But as a frontend developer building UI components, your users are other developers. Your props are the interface. Your TypeScript types are the documentation. Your default values set the expectations. If a developer has to read a long doc page just to render your component correctly, your API has failed. A great component API should feel obvious. You look at the props once and you get it.

Think about the best tools you've used in web development. They don't require a manual. Tailwind CSS classes read like plain English. Next.js file-based routing just makes sense. The best React component libraries follow the same principle: the API surface is small, naming is intuitive, and the defaults are smart enough that most usage requires minimal configuration.

┌─────────────────────┐     ┌─────────────────────┐     ┌─────────────────────┐
│    Bad API           │     │    OK API             │     │    Great API         │
│                     │     │                       │     │                     │
│  12 required props  │     │  3 required props     │     │  0 required props   │
│  Unclear naming     │────▶│  Decent naming        │────▶│  Obvious naming     │
│  No defaults        │     │  Some defaults        │     │  Smart defaults     │
│  No TypeScript      │     │  Basic types          │     │  Rich types         │
│  Rigid structure    │     │  Some flexibility     │     │  Full composition   │
└─────────────────────┘     └─────────────────────┘     └─────────────────────┘

   Your component API is the developer experience (DX).
   Great DX = adoption. Bad DX = everyone builds their own.

Rule 1: Less Props, More Composition

This is the number one mistake I see in component libraries. Too many props. Every prop is a decision the user has to make. Every prop is a code path you have to maintain. And every prop makes the component harder to understand at a glance. The solution? Composition. Instead of cramming everything into one component via props, break it into smaller pieces that users can assemble.

When you add a prop, you're adding complexity in three places: the API surface, the implementation, and the documentation. A component with 12 props has an exponential number of possible combinations. Testing all those combinations is practically impossible. But when you split that same functionality into composable sub-components, each piece is simple and testable on its own. The user assembles them like building blocks, and they can see exactly what their component will look like just by reading the JSX.

less-props.tsx

The Composition Pattern in Practice

Look at how shadcn/ui designs its components. A Dialog isn't one component with 15 props. It'sDialog, DialogTrigger, DialogContent, DialogHeader,DialogTitle, and DialogDescription. Each piece is simple on its own. Together, they're incredibly flexible. Users can rearrange them, add custom content between them, and build layouts you never imagined. This approach has become the gold standard in modern React component libraries, and for good reason.

The composition pattern also makes your components more resilient to change. Need to add a footer to your Dialog? Just create a DialogFooter component. Existing code doesn't break. No migration needed. Compare that to the prop-heavy approach where adding a footer means addingfooter, footerClassName, showFooter, andfooterAlignment props. That's four new props versus one new component.

When Props Are Better Than Composition

Composition isn't always the answer. For simple variants like color or size, props are perfect. You don't want users to compose a ButtonSmall or ButtonLarge. Asize prop makes way more sense there. The rule of thumb: use props for simple variations, composition for structural flexibility. If a prop controls styling, it's fine. If a prop controls structure or content, consider composition instead.

Decision: Props vs Composition
══════════════════════════════

           ┌──────────────────────────┐
           │  Does the prop control   │
           │  styling or structure?   │
           └────────────┬─────────────┘
                        │
              ┌─────────┴─────────┐
              ▼                   ▼
     ┌──────────────┐   ┌──────────────────┐
     │   STYLING     │   │   STRUCTURE       │
     │               │   │                  │
     │  Use a prop:  │   │  Use composition: │
     │  - variant    │   │  - AlertTitle    │
     │  - size       │   │  - AlertAction   │
     │  - color      │   │  - DialogHeader  │
     │  - disabled   │   │  - CardFooter    │
     └──────────────┘   └──────────────────┘

  Props for "how it looks."
  Composition for "what it contains."

Rule 2: Smart Defaults Make Everything Easier

The simplest version of your component should just work. No required props except maybechildren. Everything else should have a sensible default. Think about what 80% of users will want, and make that the default. This is one of the most important principles in frontend development, and it applies to everything from React components to API design.

I call this the "zero-config ideal." Can a developer use your component without passing any props? If yes, you've nailed it. Every required prop is friction. Every missing default is a decision the user has to make before they can even see something on screen. Think about how Next.js works: you create a page file and it just renders. No configuration needed. Your UI components should have the same philosophy.

defaults.tsx
The type="button" Default

Here's a real-world example of why defaults matter. In HTML, a button's default type issubmit. That means if your Button component doesn't set a default type, every button inside a form will submit it when clicked. This causes countless bugs in web applications. Developers add a "Cancel" button and suddenly clicking it submits the form. The fix is simple: default to type="button". Users who want submit behavior can explicitly settype="submit". This one default prevents hours of debugging.

Progressive Enhancement Through Defaults

Good defaults enable progressive enhancement. A developer can start with the simplest version of your component and gradually customize it as their needs grow. First they use <Button>Save</Button>. Then they discover variant="outline". Then they find asChild for rendering as a link. Each step adds one concept. They never need to learn everything at once. This is what makes a design system approachable and why defaults are so critical for developer experience.

Rule 3: Consistent Naming Across Your Design System

If your Button uses variant, your Badge should too. If your Card acceptsclassName, every component should. Consistency means developers learn one pattern and apply it everywhere. They don't have to check the docs for every component. This is perhaps the most overlooked aspect of component API design, and it's what separates a professional design system from a collection of random components.

Inconsistency is expensive. If your Button uses color but your Badge usesvariant and your Alert uses type, developers have to look up the prop name for every single component. Multiply that by 50 components and you've created a developer experience nightmare. Pick a convention early, document it, and enforce it in code reviews.

Naming Conventions That Work

  • variant for visual variations (default, destructive, outline, ghost, secondary)
  • size for size variations (sm, default, lg, icon)
  • className for style overrides (always pass to root element)
  • asChild for polymorphic rendering (render as a different element)
  • onValueChange for controlled value changes (not onChange, which collides with native events)
  • defaultValue for uncontrolled component defaults
  • open / onOpenChange for disclosure components (dialogs, dropdowns, tooltips)
  • disabled for disabling interaction (always boolean, never a variant)

Why onValueChange Instead of onChange?

Native HTML elements already have an onChange event. If your custom Select component also uses onChange, there's ambiguity. Does it fire with the native event object or just the value? Radix UI popularized onValueChange which always receives just the value. It's cleaner and avoids confusion with native events. This is a great convention to adopt across your entire design system. Similarly, onOpenChange for components that open and close is much clearer than onToggle or onVisibleChange.

Create a Naming Glossary

Document your naming conventions in a glossary that all contributors can reference. When someone builds a new component, they can check the glossary to see what prop names to use. This prevents the drift that naturally happens as more people contribute to a component library. It takes 30 minutes to create and saves hundreds of hours of inconsistency cleanup later.

Rule 4: Forward Refs and Spread Props

Your component should behave like a native HTML element as much as possible. That means forwarding refs so users can programmatically focus, measure, or scroll to your component. It also means spreading remaining props onto the root element so users can add data-testid,aria-label, or any other attribute without you explicitly supporting it. This principle is fundamental to building flexible UI components that work well with the broader React ecosystem.

Why does this matter so much? Because your component lives in an ecosystem. It needs to work with testing libraries that use data-testid. It needs to work with animation libraries that need refs. It needs to work with form libraries like React Hook Form that spread refand onChange onto inputs. If your component swallows props or doesn't forward refs, you're breaking compatibility with the tools developers already use.

forward-ref.tsx

The Prop Spreading Pattern

The pattern is simple: destructure the props you need for your component logic, then spread everything else onto the root HTML element. This ensures that any prop you haven't explicitly handled still gets passed through. It's the difference between a component that works in isolation and one that works in any context. Every major UI component library follows this pattern, from shadcn/ui to Radix to Material UI. If your components don't do this, they'll feel limiting to developers who are used to the flexibility of modern React component patterns.

className Merging with cn()

Notice how className is destructured separately and merged with default classes usingcn(). This is critical. If you just spread className with the rest of the props, the user's classes would completely replace your component's styles instead of extending them. The cn() utility (which uses clsx and tailwind-merge under the hood) handles conflicts intelligently. If a user passes className="border-red-500", it replaces the default border color but keeps everything else. This is essential for Tailwind CSS based design systems.

Rule 5: Support Both Controlled and Uncontrolled Modes

Some developers want full control over a component's state. They manage it in their parent component and pass it down. Others want the component to handle its own state internally. A well-designed component supports both. This is how native HTML elements work: an input can be controlled with value and onChange, or uncontrolled withdefaultValue. Your custom components should follow the same pattern.

controlled.tsx

Implementing the Controlled/Uncontrolled Pattern

The trick is to check whether a value prop is provided. If it is, use it as the source of truth. If it isn't, use internal state initialized with defaultValue. Radix UI has a useControllableState hook that handles this pattern beautifully. You can also build your own. It's a pattern worth investing in because you'll use it in almost every interactive component in your design system.

Why Not Always Controlled?

You might wonder why bother with uncontrolled mode at all. Why not require developers to always manage state? Because it's unnecessary friction for simple use cases. If someone just wants an accordion that opens and closes, they shouldn't need to set up useState, create a handler function, and wire it all together. The uncontrolled mode lets them get something working in one line, and they can upgrade to controlled mode when they need more power. This progressive disclosure of complexity is what makes a component API feel truly well-designed.

Rule 6: TypeScript Makes Your Components Self-Documenting

Good TypeScript types mean developers barely need documentation. Their editor tells them everything: which props exist, what values are valid, which ones are optional. Invest in your types. They're the most-read part of your component library. In a world where AI-powered code completion is the norm, strong types mean better autocomplete suggestions and fewer bugs. TypeScript is essentially free documentation that never goes stale.

types.tsx

Use cva() for Variant Types

The class-variance-authority (cva) library is a perfect companion for Tailwind CSS component development. It lets you define variant styles in a structured way and automatically generates TypeScript types for each variant. Combined with VariantProps, your component gets type-safe variants for free. No manual type definitions, no keeping types in sync with styles. This is the recommended approach in shadcn/ui and has become the de facto standard for building typed React components with Tailwind CSS.

Tip: Export Your Types

Always export your component's prop types. Other developers might need to reference them when building wrapper components or passing props through. A wrapper around your Button that adds analytics tracking needs to accept the same props. If your ButtonProps aren't exported, that developer has to manually recreate the types. It's a small thing, but it shows that you've thought about the developer experience end to end.

Rule 7: Think About Accessibility from Day One

Accessible components aren't optional. They're a requirement. And the beautiful thing about building accessibility into your design system is that every app using your components gets it for free. Make sure your components handle keyboard navigation, screen reader announcements, focus management, and ARIA attributes out of the box. This is not just an ethical imperative; it's a legal one in many jurisdictions, and it makes your components better for all users, including those using keyboards, voice control, or other assistive technologies.

Use Headless Libraries as a Foundation

Libraries like Radix UI, React Aria, and Headless UI handle the hard parts of accessibility for you. They manage keyboard interactions, focus trapping, screen reader announcements, and ARIA roles. You just add your styling on top. This is why most modern design systems, including shadcn/ui, build on top of Radix. You get WAI-ARIA compliant components without having to become an accessibility expert yourself. It's the smart way to build UI components for production web applications.

Accessibility Anti-Patterns to Avoid

There are a few common mistakes I see in React component libraries. First, using divwith onClick instead of a proper button element. Second, hiding focus outlines without providing an alternative. Third, not providing aria-label for icon-only buttons. Fourth, not managing focus when opening modals or drawers. Each of these creates a broken experience for assistive technology users. The good news is that if you build on top of Radix or similar libraries, most of these are handled automatically.

Real-World API Design Decisions

Let me walk through some concrete decisions you'll face when building components for a web development project. These are the questions that come up every time you sit down to design a new component API.

Should I Use Children or a Render Prop?

Default to children. It's the most familiar pattern in React and covers 90% of cases. Only reach for render props when the child needs data from the parent component that it can't get any other way. For example, an Autocomplete component might use a render prop for each suggestion so it can pass the match highlight information. But a Dialog? Just use children.

Should I Use Boolean Props or String Variants?

Avoid boolean props when there are more than two states. variant="outline" is better than outlined because you can easily add variant="ghost"later. Boolean props lock you into two states forever. They also create confusing combinations: what does it mean when both outlined and ghost are true? With a string variant, the type system prevents that impossible state entirely.

Should I Accept a className or Style Object?

In Tailwind CSS projects, always className. It's the standard convention, it works with all the Tailwind tooling, and the cn() utility handles class merging intelligently. Style objects have their place in CSS-in-JS libraries, but if you're building components for a Tailwind-based design system, className is the way to go. Don't try to support both; pick one convention and be consistent.

The Bottom Line

  • Fewer props, more composition. Let users build with flexible pieces.
  • The simplest usage should need zero or one prop. Smart defaults handle the rest.
  • Same naming across every component. variant, size, className everywhere.
  • Forward refs and spread props so components behave like native HTML elements.
  • Support both controlled and uncontrolled modes for interactive components.
  • Invest in TypeScript types. They replace half your documentation.
  • Build on accessible foundations like Radix UI. Don't reinvent the wheel.

Component API design is a skill that compounds. Every good decision you make now saves hundreds of developers from confusion later. And the best part? These patterns apply whether you're building a small project's UI components or a company-wide design system. Whether you're working with React, Next.js, Tailwind CSS, or any other modern frontend development stack, get the API right, and everything else follows. Your components become a joy to use instead of a source of frustration. And that's the whole point of building a design system in the first place.

Related Articles

Your Product
Deserves a better ui