Learn

/

Styling API

Styling API

8 patterns

Choosing between className, style, variants, and design tokens to keep styling concerns out of your prop API.

Avoid
interface BadgeProps {
  children: React.ReactNode;
  backgroundColor?: string;
  textColor?: string;
  borderRadius?: number;
  fontSize?: number;
}

Prefer
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.

MUI: Chip API
Avoid
interface CardProps {
  children: React.ReactNode;
  variant?: 'elevated' | 'outlined';
}

function Card({ children, variant }: CardProps) {
  return (
    <div className={styles[variant ?? 'elevated']}>
      {children}
    </div>
  );
}

Prefer
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.

Radix: Styling Guide
Avoid
interface TooltipProps {
  content: string;
  children: React.ReactNode;
  /** Custom width for the tooltip. */
  style?: React.CSSProperties;
}

Prefer
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.

React Docs: Applying CSS Styles
Avoid
interface ButtonProps {
  children: React.ReactNode;
  /** Any valid CSS color. */
  color?: string;
  /** Any valid CSS color. */
  hoverColor?: string;
}

Prefer
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.

MUI: Button API
Avoid
interface DialogProps {
  title: string;
  children: React.ReactNode;
  headerClassName?: string;
  bodyClassName?: string;
  footerClassName?: string;
  overlayClassName?: string;
  closeButtonClassName?: string;
}

Prefer
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.

MUI: Overriding Nested Styles
Avoid
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;
}

Prefer
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.

MDN: CSS Custom Properties
Avoid
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';
}

Prefer
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.

React Docs: Passing Props
Avoid
interface GridProps {
  children: React.ReactNode;
  columns: number;
  mobileColumns?: number;
  tabletColumns?: number;
  desktopColumns?: number;
}

Prefer
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.

MUI: Grid API