DEV Community

Aaron Reisman
Aaron Reisman

Posted on

Why Exposing className in React UI Components is an Anti-Pattern

Why Exposing className in React UI Components is an Anti-Pattern

When building reusable React components, one of the most common debates revolves around whether to expose a className prop to consumers. While it might seem like a flexible solution that gives developers maximum control, exposing className directly is actually an anti-pattern that can lead to brittle, unmaintainable code. Here's why you should think twice before adding that className prop to your UI components.

The Tempting Appeal of className

At first glance, exposing className seems like the ultimate flexibility solution:

// Seems flexible, right?
function Button({ children, className, ...props }) {
  return (
    <button className={`btn ${className || ''}`} {...props}>
      {children}
    </button>
  );
}

// Usage
<Button className="my-custom-styles">Click me</Button>
Enter fullscreen mode Exit fullscreen mode

This approach appears to solve the customization problem by letting consumers add their own styles. However, this convenience comes with significant hidden costs.

The Problems with Exposing className

1. Breaks Encapsulation

Components should be black boxes with well-defined interfaces. When you expose className, you're essentially saying "feel free to reach into my internals and modify whatever you want." This breaks the fundamental principle of encapsulation.

// Consumer can now break your component's styling
<Button className="btn-override-everything">
  This might look completely wrong
</Button>
Enter fullscreen mode Exit fullscreen mode

2. Creates Tight Coupling

Exposing className creates a tight coupling between your component's internal CSS structure and the consumer's code. If you need to refactor your component's styling or change CSS frameworks, you risk breaking every consumer who relied on specific class names.

// Your component internally uses Bootstrap
function Alert({ className, children }) {
  return <div className={`alert alert-info ${className}`}>{children}</div>;
}

// Consumer writes CSS that depends on your internal classes
// .alert.my-custom-alert { ... }

// Later, you switch to Tailwind - consumer code breaks!
function Alert({ className, children }) {
  return <div className={`bg-blue-100 p-4 ${className}`}>{children}</div>;
}
Enter fullscreen mode Exit fullscreen mode

3. Unpredictable Styling Conflicts

CSS specificity wars become inevitable when consumers can inject arbitrary classes. Your carefully crafted component styles might be overridden in unexpected ways, leading to inconsistent UI across your application.

// Consumer accidentally overrides critical styles
<Button className="text-red-500">
  {/* Button might lose its intended styling */}
</Button>
Enter fullscreen mode Exit fullscreen mode

4. Poor Developer Experience

Exposing className shifts the burden to consumers to understand your component's internal structure and CSS specificity rules. This creates a poor developer experience and increases the learning curve.

5. Inconsistent Design System

When every consumer can style components arbitrarily, maintaining a consistent design system becomes nearly impossible. Your carefully designed component variants get diluted by one-off customizations.

Better Alternatives

Instead of exposing className, consider these more robust approaches:

1. Comprehensive Variant System

Design your components with a complete set of variants that cover common use cases:

function Button({ 
  variant = 'primary', 
  size = 'medium', 
  disabled = false,
  children 
}) {
  const baseClasses = 'btn';
  const variantClasses = {
    primary: 'btn-primary',
    secondary: 'btn-secondary',
    danger: 'btn-danger'
  };
  const sizeClasses = {
    small: 'btn-sm',
    medium: 'btn-md',
    large: 'btn-lg'
  };

  const className = [
    baseClasses,
    variantClasses[variant],
    sizeClasses[size],
    disabled && 'btn-disabled'
  ].filter(Boolean).join(' ');

  return <button className={className}>{children}</button>;
}

// Usage - clear and intentional
<Button variant="danger" size="large">Delete Account</Button>
Enter fullscreen mode Exit fullscreen mode

2. CSS Custom Properties for Theming

Use CSS custom properties to expose specific, controlled customization points:

function ProgressBar({ progress, color }) {
  return (
    <div 
      className="progress-bar"
      style={{ '--progress-color': color }}
    >
      <div 
        className="progress-fill"
        style={{ width: `${progress}%` }}
      />
    </div>
  );
}

/* CSS */
.progress-bar {
  background: #f0f0f0;
  border-radius: 4px;
  overflow: hidden;
}

.progress-fill {
  background: var(--progress-color, #007bff);
  height: 100%;
  transition: width 0.3s ease;
}
Enter fullscreen mode Exit fullscreen mode

3. Composition Patterns

Use composition to allow customization while maintaining encapsulation:

function Card({ children }) {
  return <div className="card">{children}</div>;
}

function CardHeader({ children }) {
  return <div className="card-header">{children}</div>;
}

function CardBody({ children }) {
  return <div className="card-body">{children}</div>;
}

// Usage - flexible but controlled
<Card>
  <CardHeader>
    <h2>My Title</h2>
  </CardHeader>
  <CardBody>
    <p>Content goes here</p>
  </CardBody>
</Card>
Enter fullscreen mode Exit fullscreen mode

4. Slot-Based Architecture

Implement slots for specific customization points:

function Modal({ title, footer, children }) {
  return (
    <div className="modal">
      <div className="modal-header">
        {title}
      </div>
      <div className="modal-body">
        {children}
      </div>
      {footer && (
        <div className="modal-footer">
          {footer}
        </div>
      )}
    </div>
  );
}

// Usage
<Modal 
  title={<h1>Custom Title</h1>}
  footer={<Button>Save</Button>}
>
  <p>Modal content</p>
</Modal>
Enter fullscreen mode Exit fullscreen mode

When className Might Be Acceptable

There are very limited cases where exposing className might be justified:

Migration Periods

During transitions from legacy systems, className exposure should be treated as technical debt with a clear plan to remove it.

Branded Types for Specific Use Cases

For very specific scenarios like responsive styling, you can use branded types with semantic prop names that hide the className implementation:
Using TypeScript branded types to provide controlled responsive styling with static class references that work with Tailwind's purging:

// Create a branded type for responsive classNames
declare const __brand: unique symbol;
type ResponsiveClassName = string & { [__brand]: 'responsive-className' };

// Static class mappings that Tailwind can detect
export const RESPONSIVE_BUTTON_CLASSES = {
  'sm-md': 'sm:text-sm sm:px-2 md:text-base md:px-4',
  'sm-lg': 'sm:text-sm sm:px-2 lg:text-lg lg:px-6',
  'md-xl': 'sm:text-base sm:px-4 xl:text-xl xl:px-8',
} as const satisfies Record<string, ResponsiveClassName>;

function Button({ 
  children, 
  responsive,
  ...props 
}: {
  children: React.ReactNode;
  responsive?: ResponsiveClassName;
}) {
  return (
    <button className={`btn ${responsive || ''}`} {...props}>
      {children}
    </button>
  );
}

// Valid - using exported mapping directly
<Button responsive={RESPONSIVE_BUTTON_CLASSES['sm-lg']}>
  Responsive Button
</Button>

// TypeScript error - can't use arbitrary strings
<Button responsive="arbitrary-custom-class">
  This won't compile
</Button>
Enter fullscreen mode Exit fullscreen mode

This approach maintains static class references that Tailwind's purging can detect while providing controlled access through exported mappings. All classes are defined statically, ensuring Tailwind includes them in the final bundle.

Even well-intentioned exceptions like "layout components" or "low-level primitives" are anti-patterns that should be avoided. Instead of generic Box or Brick components, create purpose-built layout components with semantic interfaces like HStack, VStack, or Grid with explicit props for spacing, alignment, and distribution. These should be used sparingly and with clear, constrained APIs.

Best Practices for Component APIs

  1. Start Restrictive: Begin with a minimal, opinionated API. You can always add flexibility later, but removing it is a breaking change.

  2. Think in Design Tokens: Expose semantic properties (variant, size, emphasis) rather than visual properties (className, style).

  3. Provide Escape Hatches Carefully: If you must provide customization, do it through controlled mechanisms like CSS custom properties or data attributes.

  4. Document Intentions: Make it clear what customizations are supported and what might break in future versions.

Conclusion

While exposing className might seem like the path of least resistance, it ultimately leads to fragile, tightly-coupled code that's difficult to maintain and evolve. By designing thoughtful component APIs with proper variants, composition patterns, and controlled customization points, you can provide the flexibility developers need while maintaining the integrity and consistency of your design system.

Remember: good component design is about finding the right balance between flexibility and constraints. Your future self (and your teammates) will thank you for choosing the more disciplined approach.

Top comments (0)