Component API Design for Consistent Patterns: Props, Variants, and Composition
Every component library eventually faces the same problem: developers use components inconsistently because the API doesn't guide them toward correct patterns. One developer uses <Button type="primary">, another uses <Button variant="primary">, and a third writes <Button className="btn-primary">. The components work, but the codebase becomes a maze of overlapping patterns.
The root issue isn't developer discipline—it's API design. When component interfaces are ambiguous or overly flexible, inconsistency is inevitable. When they encode constraints and patterns directly into the type system, consistency becomes automatic.
Design tokens solve the "what" (color values, spacing, typography). Component API design solves the "how" (interaction patterns, composition rules, variant systems). Together, they create a system where correct usage is the path of least resistance.
This guide covers component API patterns that enforce consistency: prop naming conventions, variant systems, polymorphic types, and composition patterns. When AI assistants generate components with these APIs, they automatically produce maintainable, consistent code.
The Problem with Flexible APIs
Consider a typical button component:
// Too flexible - allows arbitrary customization
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
className?: string
style?: React.CSSProperties
type?: string
variant?: string
size?: string
color?: string
disabled?: boolean
}
This API creates several problems:
- Overlapping concerns:
className,style,color, andvariantall affect appearance - No constraints:
type,variant, andsizeaccept any string - Unclear hierarchy: Which prop takes precedence when they conflict?
- Escape hatches everywhere:
classNameandstylebypass the design system
A well-designed API constrains choices to valid options:
// Constrained - guides toward correct usage
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
fullWidth?: boolean
}
Now TypeScript enforces valid values. Developers can't create arbitrary button styles—they choose from predefined variants that align with the design system.
Prop Naming Conventions
Consistent naming across components reduces cognitive load.
Standard Props
These should work identically across all components:
// Standard naming patterns
interface StandardProps {
// Visual variants
variant?: 'primary' | 'secondary' | 'tertiary' // Not "type" or "kind"
size?: 'sm' | 'md' | 'lg' // Not "dimension" or "scale"
// State
disabled?: boolean // Not "isDisabled"
loading?: boolean // Not "isLoading"
error?: boolean // Not "hasError"
// Layout
fullWidth?: boolean // Not "block" or "fluid"
// Interaction
onClick?: () => void // Standard React naming
onChange?: (value: T) => void // Standard React naming
}
Semantic Prop Groups
Group related props with clear prefixes:
interface InputProps {
// Value and change handling
value: string
onChange: (value: string) => void
// Validation state
error?: string
success?: string
warning?: string
// Accessibility
'aria-label'?: string
'aria-describedby'?: string
'aria-invalid'?: boolean
// Descriptive content
label?: string
hint?: string
placeholder?: string
// Configuration
type?: 'text' | 'email' | 'password' | 'tel'
required?: boolean
disabled?: boolean
readOnly?: boolean
autoComplete?: string
}
Notice how validation states (error, success, warning) follow the same pattern—no mixing isError with hasSuccess.
Variant Systems
Variants define predefined style combinations:
// Button variants encode complete visual patterns
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
interface ButtonProps {
variant?: ButtonVariant
size?: 'sm' | 'md' | 'lg'
children: React.ReactNode
onClick?: () => void
disabled?: boolean
}
export function Button({
variant = 'primary',
size = 'md',
children,
onClick,
disabled = false,
}: ButtonProps) {
// Variant styles from design tokens
const variantStyles = {
primary: {
backgroundColor: tokens.color.interactivePrimary,
color: tokens.color.textInverse,
border: 'none',
},
secondary: {
backgroundColor: tokens.color.interactiveSecondary,
color: tokens.color.textPrimary,
border: `1px solid ${tokens.color.borderDefault}`,
},
ghost: {
backgroundColor: 'transparent',
color: tokens.color.interactivePrimary,
border: 'none',
},
danger: {
backgroundColor: tokens.color.statusError,
color: tokens.color.textInverse,
border: 'none',
},
}
const sizeStyles = {
sm: {
height: '2rem',
padding: '0 0.75rem',
fontSize: '0.875rem',
},
md: {
height: '2.5rem',
padding: '0 1rem',
fontSize: '1rem',
},
lg: {
height: '3rem',
padding: '0 1.5rem',
fontSize: '1.125rem',
},
}
return (
<button
onClick={onClick}
disabled={disabled}
style={{
...variantStyles[variant],
...sizeStyles[size],
borderRadius: '0.375rem',
fontWeight: '500',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: 'all 150ms ease',
}}
>
{children}
</button>
)
}
Compound Variants
Sometimes you need style combinations based on multiple props:
// Alert component with status × variant combinations
type AlertStatus = 'success' | 'warning' | 'error' | 'info'
type AlertVariant = 'filled' | 'outlined' | 'subtle'
interface AlertProps {
status: AlertStatus
variant?: AlertVariant
children: React.ReactNode
}
export function Alert({
status,
variant = 'subtle',
children
}: AlertProps) {
// Base status colors
const statusColors = {
success: tokens.color.statusSuccess,
warning: tokens.color.statusWarning,
error: tokens.color.statusError,
info: tokens.color.statusInfo,
}
// Variant styles depend on both status and variant
const getStyles = () => {
const baseColor = statusColors[status]
switch (variant) {
case 'filled':
return {
backgroundColor: baseColor,
color: tokens.color.textInverse,
border: 'none',
}
case 'outlined':
return {
backgroundColor: 'transparent',
color: baseColor,
border: `2px solid ${baseColor}`,
}
case 'subtle':
return {
backgroundColor: tokens.color.backgroundTertiary,
color: tokens.color.textPrimary,
borderLeft: `4px solid ${baseColor}`,
}
}
}
return (
<div style={{
...getStyles(),
padding: '1rem',
borderRadius: '0.375rem',
display: 'flex',
gap: '0.75rem',
}}>
{children}
</div>
)
}
Usage:
<Alert status="success" variant="filled">Operation completed</Alert>
<Alert status="error" variant="outlined">Invalid input</Alert>
<Alert status="info" variant="subtle">Pro tip: Use keyboard shortcuts</Alert>
Polymorphic Components
Components that can render as different HTML elements:
// Polymorphic "as" prop pattern
type PolymorphicProps<E extends React.ElementType> = {
as?: E
children: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<E>, 'as' | 'children'>
export function Text<E extends React.ElementType = 'span'>({
as,
children,
...props
}: PolymorphicProps<E>) {
const Component = as || 'span'
return (
<Component
style={{
color: tokens.color.textPrimary,
fontSize: tokens.typography.fontSize.base,
}}
{...props}
>
{children}
</Component>
)
}
Usage with full type safety:
// Renders as <span>
<Text>Default span</Text>
// Renders as <p>
<Text as="p">Paragraph text</Text>
// Renders as <a> with href (TypeScript knows 'href' is valid)
<Text as="a" href="https://example.com">Link text</Text>
// TypeScript error: 'href' not valid for 'p'
<Text as="p" href="...">Error</Text>
Composition Patterns
Design components that work together predictably.
Slot-Based Composition
Provide named slots for flexible layouts:
interface CardProps {
header?: React.ReactNode
footer?: React.ReactNode
children: React.ReactNode
variant?: 'default' | 'elevated'
}
export function Card({
header,
footer,
children,
variant = 'default'
}: CardProps) {
return (
<div style={{
backgroundColor: tokens.color.surfaceBase,
border: `1px solid ${tokens.color.borderDefault}`,
borderRadius: '0.5rem',
boxShadow: variant === 'elevated' ? tokens.elevation.medium : 'none',
}}>
{header && (
<div style={{
padding: '1rem',
borderBottom: `1px solid ${tokens.color.borderSubtle}`,
fontWeight: '600',
}}>
{header}
</div>
)}
<div style={{ padding: '1rem' }}>
{children}
</div>
{footer && (
<div style={{
padding: '1rem',
borderTop: `1px solid ${tokens.color.borderSubtle}`,
}}>
{footer}
</div>
)}
</div>
)
}
Usage:
<Card
header="User Profile"
footer={<Button>Save Changes</Button>}
>
<p>Profile content here</p>
</Card>
Compound Components
Components that work together through shared context:
// Tab system using compound components
interface TabsContextValue {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
function Tabs({
defaultTab,
children
}: {
defaultTab: string
children: React.ReactNode
}) {
const [activeTab, setActiveTab] = React.useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div>{children}</div>
</TabsContext.Provider>
)
}
function TabList({ children }: { children: React.ReactNode }) {
return (
<div style={{
display: 'flex',
gap: '0.5rem',
borderBottom: `1px solid ${tokens.color.borderDefault}`,
marginBottom: '1rem',
}}>
{children}
</div>
)
}
function Tab({
value,
children
}: {
value: string
children: React.ReactNode
}) {
const context = React.useContext(TabsContext)
if (!context) throw new Error('Tab must be inside Tabs')
const isActive = context.activeTab === value
return (
<button
onClick={() => context.setActiveTab(value)}
style={{
padding: '0.75rem 1rem',
border: 'none',
backgroundColor: 'transparent',
color: isActive
? tokens.color.interactivePrimary
: tokens.color.textSecondary,
borderBottom: isActive
? `2px solid ${tokens.color.interactivePrimary}`
: '2px solid transparent',
cursor: 'pointer',
fontWeight: isActive ? '600' : '400',
}}
>
{children}
</button>
)
}
function TabPanel({
value,
children
}: {
value: string
children: React.ReactNode
}) {
const context = React.useContext(TabsContext)
if (!context) throw new Error('TabPanel must be inside Tabs')
if (context.activeTab !== value) return null
return <div>{children}</div>
}
// Export compound component
export { Tabs, TabList, Tab, TabPanel }
Usage:
<Tabs defaultTab="profile">
<TabList>
<Tab value="profile">Profile</Tab>
<Tab value="settings">Settings</Tab>
<Tab value="billing">Billing</Tab>
</TabList>
<TabPanel value="profile">
<ProfileContent />
</TabPanel>
<TabPanel value="settings">
<SettingsContent />
</TabPanel>
<TabPanel value="billing">
<BillingContent />
</TabPanel>
</Tabs>
This pattern makes relationships explicit and prevents misuse (can't use Tab outside Tabs).
Render Props Pattern
For maximum flexibility without losing type safety:
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
emptyState?: React.ReactNode
}
export function List<T>({
items,
renderItem,
emptyState = <p>No items</p>
}: ListProps<T>) {
if (items.length === 0) {
return <>{emptyState}</>
}
return (
<ul style={{
listStyle: 'none',
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}>
{items.map((item, index) => (
<li key={index}>{renderItem(item, index)}</li>
))}
</ul>
)
}
Usage with full type inference:
interface User {
id: string
name: string
email: string
}
const users: User[] = [...]
<List
items={users}
renderItem={(user) => (
// TypeScript knows 'user' is type User
<div>
<strong>{user.name}</strong>
<p>{user.email}</p>
</div>
)}
emptyState={<p>No users found</p>}
/>
Controlled vs. Uncontrolled Components
Support both patterns for flexibility:
interface InputProps {
// Controlled
value?: string
onChange?: (value: string) => void
// Uncontrolled
defaultValue?: string
// Common props
label: string
type?: 'text' | 'email' | 'password'
}
export function Input({
value: controlledValue,
onChange,
defaultValue,
label,
type = 'text',
}: InputProps) {
// Determine if controlled
const isControlled = controlledValue !== undefined
// Internal state for uncontrolled mode
const [internalValue, setInternalValue] = React.useState(defaultValue || '')
const value = isControlled ? controlledValue : internalValue
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
if (!isControlled) {
setInternalValue(newValue)
}
onChange?.(newValue)
}
return (
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontSize: '0.875rem',
fontWeight: '500',
}}>
{label}
</label>
<input
type={type}
value={value}
onChange={handleChange}
style={{
width: '100%',
padding: '0.75rem',
border: `1px solid ${tokens.color.borderDefault}`,
borderRadius: '0.375rem',
fontSize: '1rem',
}}
/>
</div>
)
}
Usage:
// Controlled
const [email, setEmail] = useState('')
<Input label="Email" value={email} onChange={setEmail} />
// Uncontrolled
<Input label="Email" defaultValue="[email protected]" />
AI-Friendly Component APIs
When AI assistants generate components, clear APIs guide them:
Prompt:
Create a modal dialog component using our component API patterns.
Should support: title, content, footer actions, close callback.
Use design tokens from @/tokens.
AI Output:
import { tokens } from '@/tokens'
import { Button } from '@/components/Button'
import React from 'react'
interface ModalProps {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
footer?: React.ReactNode
size?: 'sm' | 'md' | 'lg'
}
export function Modal({
open,
onClose,
title,
children,
footer,
size = 'md',
}: ModalProps) {
if (!open) return null
const sizeStyles = {
sm: { maxWidth: '400px' },
md: { maxWidth: '600px' },
lg: { maxWidth: '800px' },
}
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: tokens.color.surfaceBase,
borderRadius: '0.5rem',
boxShadow: tokens.elevation.highest,
width: '90%',
...sizeStyles[size],
}}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1.5rem',
borderBottom: `1px solid ${tokens.color.borderDefault}`,
}}>
<h2 style={{
fontSize: '1.25rem',
fontWeight: '600',
color: tokens.color.textPrimary,
}}>
{title}
</h2>
<button
onClick={onClose}
style={{
border: 'none',
background: 'none',
fontSize: '1.5rem',
cursor: 'pointer',
color: tokens.color.textSecondary,
}}
>
×
</button>
</div>
{/* Content */}
<div style={{
padding: '1.5rem',
color: tokens.color.textPrimary,
}}>
{children}
</div>
{/* Footer */}
{footer && (
<div style={{
padding: '1.5rem',
borderTop: `1px solid ${tokens.color.borderDefault}`,
display: 'flex',
justifyContent: 'flex-end',
gap: '0.75rem',
}}>
{footer}
</div>
)}
</div>
</div>
)
}
The AI followed the established patterns:
- Standard prop names (
open,onClose,size) - Variant system for sizes
- Slot-based composition (
footer) - Design token references
- TypeScript types
FramingUI Component APIs
FramingUI components follow these patterns out of the box:
import { Button, Card, Input, Modal } from 'framingui'
// Consistent variant systems
<Button variant="primary" size="md">Submit</Button>
// Slot-based composition
<Card
header="Title"
footer={<Button>Action</Button>}
>
Content
</Card>
// Controlled/uncontrolled support
<Input label="Email" value={email} onChange={setEmail} />
<Input label="Name" defaultValue="John" />
// Compound components
<Tabs defaultTab="profile">
<TabList>
<Tab value="profile">Profile</Tab>
</TabList>
<TabPanel value="profile">Content</TabPanel>
</Tabs>
When connected to AI assistants via MCP, they understand these patterns and generate components that fit seamlessly into your codebase.
Best Practices
- Limit escape hatches: Avoid
classNameandstyleprops unless absolutely necessary - Use enums for variants: TypeScript enums or string unions, never plain strings
- Consistent naming: Same prop names across similar components
- Composition over configuration: Prefer slots and compound components over giant prop interfaces
- Document patterns: Comment why APIs are designed a certain way
Conclusion
Component API design determines whether your design system enables consistency or fights against it. Well-designed APIs:
- Guide developers toward correct patterns through type constraints
- Eliminate ambiguity with clear prop hierarchies and naming conventions
- Enable AI assistants to generate maintainable code automatically
- Scale gracefully as your component library grows
Combined with design tokens (which solve the "what"), component API patterns solve the "how"—creating a complete system where consistency is automatic, not aspirational.
Further reading: