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>
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>
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>;
}
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>
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>
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;
}
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>
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>
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>
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
Start Restrictive: Begin with a minimal, opinionated API. You can always add flexibility later, but removing it is a breaking change.
Think in Design Tokens: Expose semantic properties (
variant
,size
,emphasis
) rather than visual properties (className
,style
).Provide Escape Hatches Carefully: If you must provide customization, do it through controlled mechanisms like CSS custom properties or data attributes.
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)