Component API Design: Principles for Reusable UI
Explore the principles and patterns behind well-designed component APIs, from prop naming conventions to compound component patterns in TypeScript and React.
The most important feature of a design system component is not how it looks — it is how it feels to use. A component's API is the surface area that every developer on your team interacts with daily. A well-designed API is intuitive, discoverable, and hard to misuse. A poorly designed one generates confusion, Slack questions, and workarounds that undermine the entire system. This article distills years of design system work into actionable principles for crafting component APIs that developers genuinely enjoy using.
Principle 1: Consistency Over Cleverness
The single most impactful thing you can do for your component API is make it consistent. When a developer learns how one component works, they should be able to predict how every other component works. This means establishing conventions and following them relentlessly.
Start with naming. If your Button uses variant to control its visual style, every component should use variant for the same purpose — not type on one, kind on another, and appearance on a third.
// Consistent naming across the system
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
}
interface BadgeProps {
variant?: 'info' | 'success' | 'warning' | 'error';
size?: 'sm' | 'md' | 'lg';
}
interface InputProps {
variant?: 'outlined' | 'filled' | 'flushed';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
}
interface AlertProps {
variant?: 'info' | 'success' | 'warning' | 'error';
size?: 'sm' | 'md' | 'lg';
}Notice the pattern: variant always controls visual style, size always uses the same scale, and boolean states like disabled use the same name. Document these conventions explicitly and enforce them through code review.
Sizing deserves special attention. Use a consistent t-shirt scale (sm, md, lg) rather than numbers or pixel values. This abstraction decouples the API from implementation details and lets you adjust actual sizes without breaking consumers:
// Define the scale once
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// Map to actual values internally
const sizeMap: Record<Size, { height: string; fontSize: string; padding: string }> = {
xs: { height: '24px', fontSize: '12px', padding: '0 8px' },
sm: { height: '32px', fontSize: '14px', padding: '0 12px' },
md: { height: '40px', fontSize: '16px', padding: '0 16px' },
lg: { height: '48px', fontSize: '18px', padding: '0 20px' },
xl: { height: '56px', fontSize: '20px', padding: '0 24px' },
};Principle 2: Prop Interfaces That Guide Correct Usage
TypeScript's type system is your most powerful tool for API design. Use it to make incorrect usage impossible, not just unlikely.
Discriminated unions prevent invalid prop combinations:
// Bad: these props can be combined in invalid ways
interface BadButtonProps {
href?: string;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
target?: '_blank' | '_self';
}
// Good: discriminated unions enforce valid combinations
type ButtonAsButton = {
as?: 'button';
type?: 'button' | 'submit' | 'reset';
onClick?: React.MouseEventHandler<HTMLButtonElement>;
href?: never;
target?: never;
};
type ButtonAsLink = {
as: 'a';
href: string;
target?: '_blank' | '_self';
type?: never;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
type ButtonBaseProps = {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
children: React.ReactNode;
};
type ButtonProps = ButtonBaseProps & (ButtonAsButton | ButtonAsLink);
const Button = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
({ as, variant = 'primary', size = 'md', isLoading, children, ...props }, ref) => {
const Component = as === 'a' ? 'a' : 'button';
return (
<Component
ref={ref as any}
className={cn(buttonStyles({ variant, size }))}
{...props}
>
{isLoading ? <Spinner size={size} /> : children}
</Component>
);
}
);Now TypeScript prevents developers from passing href to a button or type to a link. The never type marks props that are explicitly disallowed for a given variant.
Required vs. optional props should follow a clear philosophy: props that affect core behavior are required; props that have sensible defaults are optional. Every optional prop must have a default value documented in the interface and applied in the implementation:
interface TooltipProps {
/** The content to display in the tooltip */
content: React.ReactNode; // Required: no sensible default
/** The element that triggers the tooltip */
children: React.ReactElement; // Required: no sensible default
/** Which side to place the tooltip on @default 'top' */
placement?: 'top' | 'bottom' | 'left' | 'right';
/** Delay before showing in milliseconds @default 200 */
openDelay?: number;
/** Whether the tooltip is disabled @default false */
isDisabled?: boolean;
}JSDoc comments on interface properties are not just nice documentation — most IDEs display them inline during autocompletion, making the API self-documenting.
Principle 3: The Compound Component Pattern
Some components have inherently complex APIs. A Modal, for instance, needs a trigger, a header, a body, a footer, and close behavior. Cramming everything into a flat prop interface creates an unwieldy API:
// Flat API — quickly becomes overwhelming
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Confirm Action"
description="Are you sure you want to proceed?"
body={<p>This cannot be undone.</p>}
footer={
<>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="danger" onClick={handleConfirm}>Delete</Button>
</>
}
showCloseButton={true}
closeOnOverlayClick={true}
size="md"
/>The compound component pattern distributes this complexity across composable sub-components:
// Compound component API — clear, flexible, composable
<Modal isOpen={isOpen} onClose={handleClose}>
<Modal.Header>
<Modal.Title>Confirm Action</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<p>This cannot be undone.</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="danger" onClick={handleConfirm}>Delete</Button>
</Modal.Footer>
</Modal>Implementing this in TypeScript requires careful use of context and namespace patterns:
import React, { createContext, useContext } from 'react';
interface ModalContextValue {
isOpen: boolean;
onClose: () => void;
}
const ModalContext = createContext<ModalContextValue | null>(null);
function useModalContext(): ModalContextValue {
const context = useContext(ModalContext);
if (!context) {
throw new Error('Modal compound components must be used within a Modal');
}
return context;
}
interface ModalRootProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
function ModalRoot({ isOpen, onClose, children }: ModalRootProps) {
if (!isOpen) return null;
return (
<ModalContext.Provider value={{ isOpen, onClose }}>
<div className="modal-overlay" onClick={onClose}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
</ModalContext.Provider>
);
}
function ModalHeader({ children }: { children: React.ReactNode }) {
return <div className="modal-header">{children}</div>;
}
function ModalTitle({ children }: { children: React.ReactNode }) {
return <h2 className="modal-title">{children}</h2>;
}
function ModalCloseButton() {
const { onClose } = useModalContext();
return (
<button className="modal-close" onClick={onClose} aria-label="Close modal">
×
</button>
);
}
function ModalBody({ children }: { children: React.ReactNode }) {
return <div className="modal-body">{children}</div>;
}
function ModalFooter({ children }: { children: React.ReactNode }) {
return <div className="modal-footer">{children}</div>;
}
// Assemble the compound component
export const Modal = Object.assign(ModalRoot, {
Header: ModalHeader,
Title: ModalTitle,
CloseButton: ModalCloseButton,
Body: ModalBody,
Footer: ModalFooter,
});The Object.assign pattern preserves full TypeScript support. Consumers get autocompletion for Modal.Header, Modal.Body, and so on, and the context-based error boundary catches misuse at runtime.
Principle 4: Ref Forwarding and HTML Attribute Pass-Through
Design system components are not islands — they exist within a larger application. Consumers need to attach refs for focus management, pass data-* attributes for testing, and apply native HTML attributes that your component may not explicitly define.
Every design system component should forward refs and spread remaining props onto the root element:
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'elevated' | 'outlined' | 'filled';
padding?: 'none' | 'sm' | 'md' | 'lg';
}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ variant = 'elevated', padding = 'md', className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(cardStyles({ variant, padding }), className)}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = 'Card';Extending React.HTMLAttributes<HTMLDivElement> means consumers can pass aria-* attributes, data-testid, role, tabIndex, and anything else a div supports — without your component needing to know about them. The className prop is explicitly extracted so it can be merged with internal styles rather than overriding them.
For components that render different HTML elements, use a generic approach:
type PolymorphicRef<T extends React.ElementType> =
React.ComponentPropsWithRef<T>['ref'];
type PolymorphicProps<T extends React.ElementType, Props = {}> = Props &
Omit<React.ComponentPropsWithoutRef<T>, keyof Props> & {
as?: T;
ref?: PolymorphicRef<T>;
};
// Usage
type TextProps<T extends React.ElementType = 'span'> = PolymorphicProps<T, {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
color?: ColorToken;
}>;This allows <Text as="h1"> to properly type-check as an heading element and <Text as="label" htmlFor="email"> to accept label-specific attributes.
Principle 5: Controlled and Uncontrolled Modes
Interactive components like inputs, selects, and toggles should support both controlled and uncontrolled usage. This gives consumers flexibility: simple forms can use uncontrolled mode with defaultValue, while complex state management scenarios can use controlled mode with value and onChange.
interface SwitchProps {
/** Controlled checked state */
checked?: boolean;
/** Default checked state for uncontrolled mode */
defaultChecked?: boolean;
/** Called when the checked state changes */
onChange?: (checked: boolean) => void;
/** Accessible label */
label: string;
/** Whether the switch is disabled */
disabled?: boolean;
}
function Switch({ checked: controlledChecked, defaultChecked = false, onChange, label, disabled }: SwitchProps) {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
const isControlled = controlledChecked !== undefined;
const isChecked = isControlled ? controlledChecked : internalChecked;
const handleToggle = () => {
if (disabled) return;
if (!isControlled) {
setInternalChecked((prev) => !prev);
}
onChange?.(!isChecked);
};
return (
<label className="switch-wrapper">
<button
role="switch"
aria-checked={isChecked}
aria-label={label}
disabled={disabled}
onClick={handleToggle}
className={cn('switch-track', { 'switch-track--checked': isChecked })}
>
<span className={cn('switch-thumb', { 'switch-thumb--checked': isChecked })} />
</button>
<span className="switch-label">{label}</span>
</label>
);
}This dual-mode pattern is a hallmark of professional component libraries. It respects consumer autonomy while providing a frictionless default experience.
Conclusion
Great component APIs do not happen by accident. They are the result of deliberate decisions: consistent naming, TypeScript-enforced constraints, composable patterns for complex UIs, transparent HTML attribute forwarding, and flexible controlled/uncontrolled modes. The time you invest in API design pays dividends every day, in every file, for every developer on your team. When the API is right, the design system fades into the background — and that is exactly where it should be.
Related Articles
Versioning and Publishing Component Libraries
A practical guide to versioning, releasing, and distributing TypeScript component libraries using semantic versioning, changesets, and automated CI/CD publishing pipelines.
Measuring the ROI of a Design System
A practical framework for measuring the return on investment of a design system, covering efficiency metrics, quality improvements, consistency gains, and how to communicate value to stakeholders.
Documentation That Developers Actually Read
How to create design system documentation that developers engage with, covering information architecture, interactive examples, API references, and documentation-as-code workflows.