In my experience developing component libraries for various startups, I've noticed a recurring pattern. Time and again, teams try to break free from opinionated libraries like MUI, AntDesign, or ShadCN by building custom design systems—only to find their custom systems becoming just as rigid and limited in scope. Even when using utility-first frameworks like Tailwind CSS, if overused or misapplied, excessive prop passing forces your custom library into inflexible, project-specific patterns that can’t be easily reused elsewhere.
The core issue is that when you try to escape established opinions without a modular strategy, you end up recreating them in a form tailored to a specific project rather than being reusable across multiple projects. My experience and community feedback suggest the best solution for React is a hybrid, localized styling approach. This approach works with any CSS strategy—CSS Modules, SCSS, CSS-in-JS (like twin.macro), or even Tailwind—while keeping components reusable across projects.
The Problem: Complex & Opinionated Libraries
Popular component libraries like MUI, AntDesign, and ShadCN guide you toward a specific design language, which can sometimes limit your ability to create a truly unique look and reuse components across projects. Meanwhile, if not managed properly, even a utility-first approach (like Tailwind CSS) can lead to excessive prop passing, resulting in a custom component library that is as opinionated as the established ones and often limited to just one or two projects.
In practice, teams that try to “escape” these opinions often end up with:
- Rigid Patterns: Pre-defined design tokens and utilities enforce uniformity that may not suit your product’s unique identity—and these patterns are so tailored to one project that they hinder reuse in others.
- Excessive Prop Passing: Relying solely on utility-first approaches can result in lengthy lists of props and class overrides. For example, a button with left and right icons might require individual style props just to tweak margins, paddings, colors, and hover effects—locking the component into a project-specific setup.
- Reinventing the Wheel: In an effort to break free from established libraries, teams build custom component libraries that, without a modular strategy, inherit rigid, project-specific patterns. This not only makes them difficult to maintain but also limits their reusability across different projects.
The CSS Custom Properties Strategy for Flexible Component Libraries
The solution to building flexible, reusable components lies in leveraging CSS custom properties to create clear customization points while keeping styling choices open. This approach ensures your components remain adaptable across different projects, regardless of your team's preferred styling solution.
Let's explore this strategy through a practical example: a Button component that needs to support icons, variants, and project-specific styling requirements.
Core Component Architecture
The foundation starts with a base Button component that uses CSS custom properties for key styling points:
const Button = ({
variant = "default",
icon,
children,
className,
...rest
}) => (
<button
className={`button ${variant} ${className}`}
{...rest}
>
{icon && <IconContainer>{icon}</IconContainer>}
<span>{children}</span>
</button>
);
const IconContainer = styled.div`
height: var(--icon-container-size, 16px);
width: var(--icon-container-size, 16px);
padding: var(--icon-container-padding, 0);
margin: var(--icon-container-margin, 0 4px 0 0);
svg {
padding: var(--icon-padding, 2px);
color: var(--icon-color, currentColor);
}
`;
The base Button component should also adhere to accessibility best practices, such as appropriate ARIA attributes for screen readers.
This structure provides several key advantages:
- Clear Customization Points: CSS custom properties define exactly what can be customized.
- Default Styling: Base styles with sensible defaults.
- Flexibility: The className prop allows for easy style overrides using any styling approach.
Implementation Options
The beauty of this approach is its flexibility in implementation. Teams can choose their preferred styling solution while maintaining component consistency:
- Using CSS Modules:
// TextButton.module.scss
.textButton {
--icon-container-size: 20px;
--icon-padding: 4px;
background: transparent;
color: var(--color-text-primary);
&:hover {
background: var(--color-background-hover);
}
}
import styles from './TextButton.module.scss';
const TextButton = (props) => (
<Button className={styles.textButton} {...props} />
);
- Using Styled Components:
const TextButton = styled(Button)`
--icon-container-size: 20px;
--icon-padding: 4px;
background: transparent;
color: ${props => props.theme.colors.primary};
&:hover {
background: ${props => props.theme.colors.backgroundHover};
}
`;
// Usage
<TextButton
variant="outline"
icon={<Icon name="Info" />}
>
Learn More
</TextButton>
- Using SCSS
.text-button {
--icon-container-size: 20px;
--icon-padding: 4px;
@include button-base;
background: transparent;
&:hover {
background: $color-background-hover;
}
}
- Optional: Tailwind Integration For teams using Tailwind, the component can easily integrate with utility classes:
<Button
className="bg-transparent hover:bg-gray-50"
style={{
'--icon-container-size': '20px',
'--icon-padding': '4px'
}}
icon={<Icon name="Info" />}
>
Learn More
</Button>
Real-World Effectiveness
This strategy has proven highly effective in real-world applications for several reasons:
True Component Reusability: Instead of creating rigid, project-specific components, this approach allows teams to:
- Maintain consistent component structure.
- Customize styling for specific project needs.
- Avoid rebuilding components for each new project.
Reduced Prop Complexity: Rather than passing numerous style props, we can:
- Use CSS custom properties for style customization.
- Keep component APIs clean and focused.
- Allow styling overrides through standard CSS mechanisms.
Flexible Implementation: Teams can:
- Choose their preferred styling approach.
- Mix and match styling solutions as needed.
- Maintain consistent component behavior across different styling implementations.
Example: Building a Product Suite
Here's how this approach works in practice. Imagine building a suite of products that share common components but need distinct visual identities:
// Product A - Marketing Site
const MarketingButton = styled(Button)`
--icon-container-size: 24px;
--icon-container-margin: 0 8px 0 0;
background: linear-gradient(to right, var(--color-primary), var(--color-secondary));
color: white;
`;
// Product B - Admin Dashboard
const AdminButton = styled(Button)`
--icon-container-size: 18px;
--icon-padding: 2px;
background: var(--color-background-subtle);
color: var(--color-text-primary);
`;
Conclusion
CSS custom properties provide a powerful solution to the component library challenge. Instead of fighting against established libraries or creating rigid custom solutions, this approach:
- Maintains component flexibility while providing consistent structure.
- Supports multiple styling approaches to suit team preferences.
- Enables true component reusability across different projects.
- Reduces the complexity of style customization.
This strategy has helped numerous teams break free from the cycle of rebuilding components for each project while maintaining the flexibility needed for unique design requirements. Whether you're building a single product or a suite of applications, this approach provides a sustainable path forward for component library development.
What challenges have you faced with component libraries in your projects? How do you balance consistency with flexibility in your design systems? Share your experiences in the comments below.
Don't forget to like, share and also connect with me on LinkedIn.
Top comments (0)