Styling API
Choosing between className, style, variants, and design tokens to keep styling concerns out of your prop API.
interface BadgeProps {
children: React.ReactNode;
backgroundColor?: string;
textColor?: string;
borderRadius?: number;
fontSize?: number;
}interface BadgeProps {
children: React.ReactNode;
/** @default "default" */
variant?: 'default' | 'success' | 'warning'
| 'error' | 'info';
/** @default "md" */
size?: 'sm' | 'md' | 'lg';
}Exposing raw style values (backgroundColor, fontSize) turns every consumer into a designer. Variants encode design decisions once, so consumers pick from a curated set.
<Badge variant="success" size="sm" /> is self-documenting and guaranteed to look correct. With raw values, two developers will pick two different greens for success.
interface CardProps {
children: React.ReactNode;
variant?: 'elevated' | 'outlined';
}
function Card({ children, variant }: CardProps) {
return (
<div className={styles[variant ?? 'elevated']}>
{children}
</div>
);
}interface CardProps {
children: React.ReactNode;
variant?: 'elevated' | 'outlined';
className?: string;
}
function Card({ children, variant, className }: CardProps) {
return (
<div className={clsx(styles[variant ?? 'elevated'], className)}>
{children}
</div>
);
}A className prop is the escape hatch every component needs. Without it, consumers can't add margins, adjust positioning, or apply one-off overrides without wrapping the component in an extra <div>.
Merging with clsx (or cn) ensures the component's own styles are preserved while the consumer's additions take effect.
interface TooltipProps {
content: string;
children: React.ReactNode;
/** Custom width for the tooltip. */
style?: React.CSSProperties;
}interface TooltipProps {
content: string;
children: React.ReactNode;
/** Additional classes for the tooltip. */
className?: string;
}Exposing style as the primary customization prop encourages inline styles, which can't use media queries, pseudo-classes, or CSS variables. className integrates with any CSS methodology (Tailwind, CSS modules, styled-components) and supports responsive design.
If you need both, accept both. But className alone covers more use cases than style alone.
interface ButtonProps {
children: React.ReactNode;
/** Any valid CSS color. */
color?: string;
/** Any valid CSS color. */
hoverColor?: string;
}interface ButtonProps {
children: React.ReactNode;
/** @default "primary" */
color?: 'primary' | 'secondary' | 'error'
| 'success' | 'inherit';
}A color prop that accepts any string is impossible to validate. color="rde" (typo) compiles fine. Token-based color props constrain consumers to the design system's palette and handle hover, focus, and disabled states internally.
MUI's Button uses this exact pattern: color maps to theme palette keys, and the component derives all related colors (hover, active, text contrast) automatically.
interface DialogProps {
title: string;
children: React.ReactNode;
headerClassName?: string;
bodyClassName?: string;
footerClassName?: string;
overlayClassName?: string;
closeButtonClassName?: string;
}interface DialogProps {
title: string;
children: React.ReactNode;
className?: string;
classes?: {
header?: string;
body?: string;
footer?: string;
overlay?: string;
closeButton?: string;
};
}Grouping sub-element classNames into a classes object keeps the top-level API clean. The root className handles the common case, while classes is there for fine-grained control.
With five flat *ClassName props, the component's main API (title, children, callbacks) gets buried. MUI uses this exact classes pattern across all its components.
interface ProgressBarProps {
/** Progress from 0 to 100. */
value: number;
/** Height in pixels. @default 8 */
height?: number;
/** Track color. @default "#e5e7eb" */
trackColor?: string;
/** Fill color. @default "#3b82f6" */
fillColor?: string;
/** Border radius in pixels. @default 4 */
borderRadius?: number;
}interface ProgressBarProps {
/** Progress from 0 to 100. */
value: number;
/** @default "md" */
size?: 'sm' | 'md' | 'lg';
/** @default "primary" */
color?: 'primary' | 'success' | 'warning'
| 'error';
className?: string;
}Four separate style props (height, trackColor, fillColor, borderRadius) make the consumer responsible for visual coherence. Do height={4} and borderRadius={8} look right together? Nobody knows without rendering it.
Semantic props (size, color) express intent and let the component handle internally consistent styling. className is the escape hatch for anything the variants don't cover.
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
gap?: number;
padding?: number;
maxHeight?: number;
overflow?: 'auto' | 'hidden' | 'scroll';
display?: 'flex' | 'grid';
flexDirection?: 'row' | 'column';
}interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
/** @default "vertical" */
direction?: 'vertical' | 'horizontal';
/** @default "md" */
spacing?: 'none' | 'sm' | 'md' | 'lg';
className?: string;
}Props like display, flexDirection, and overflow leak CSS implementation details into the component API. They couple consumers to the component's internal layout strategy.
direction and spacing express the same concepts at a higher abstraction level. If the component switches from flexbox to grid internally, the API stays the same. className covers edge cases.
interface GridProps {
children: React.ReactNode;
columns: number;
mobileColumns?: number;
tabletColumns?: number;
desktopColumns?: number;
}interface GridProps {
children: React.ReactNode;
columns: number | {
xs?: number;
sm?: number;
md?: number;
lg?: number;
xl?: number;
};
}A single prop that accepts either a static value or a breakpoint object scales cleanly. columns={3} for simple cases, columns={{ xs: 1, md: 2, lg: 3 }} for responsive ones.
Separate props per breakpoint (mobileColumns, tabletColumns) don't compose. What if you add an xxl breakpoint? Every responsive prop needs a new sibling. MUI's Grid uses this responsive object pattern.