CSS Architecture: BEM, CSS Modules, Styled Components
CSS was designed for documents, not applications. As JavaScript frameworks enabled increasingly complex UIs, CSS's global namespace became a liability. Every class name exists in a single global...
Key Insights
- BEM requires zero tooling but demands strict naming discipline—it’s the most portable solution but verbosity becomes painful at scale
- CSS Modules give you true local scope with minimal learning curve, making them ideal for teams transitioning from traditional CSS who want safety without radical change
- Styled Components offer the most powerful dynamic styling capabilities but add runtime overhead and lock you into the React ecosystem
The CSS Scalability Problem
CSS was designed for documents, not applications. As JavaScript frameworks enabled increasingly complex UIs, CSS’s global namespace became a liability. Every class name exists in a single global scope, leading to naming conflicts, specificity wars, and the dreaded “I’m afraid to delete this CSS because I don’t know what will break.”
These problems compound as teams grow. Without architecture, you end up with:
- Naming conflicts requiring increasingly specific selectors
- Dead code that nobody dares remove
- Specificity battles solved with
!important - Styles that mysteriously affect unrelated components
Three architectural approaches have emerged to solve these problems: BEM’s naming convention, CSS Modules’ compile-time scoping, and Styled Components’ runtime CSS-in-JS. Each makes different tradeoffs between portability, developer experience, and performance.
BEM (Block Element Modifier)
BEM is a naming convention that creates architecture through discipline rather than tooling. You write plain CSS but follow strict naming rules that prevent conflicts and make relationships explicit.
The methodology has three parts:
- Block: A standalone component (
.card,.button,.nav) - Element: A part of a block (
.card__title,.card__image) - Modifier: A variant or state (
.button--primary,.card--featured)
Here’s a card component using BEM:
/* Block */
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
/* Elements */
.card__title {
font-size: 24px;
font-weight: bold;
margin-bottom: 8px;
}
.card__description {
color: #666;
line-height: 1.5;
}
.card__image {
width: 100%;
border-radius: 4px;
}
/* Modifiers */
.card--featured {
border-color: #0066cc;
background: #f0f8ff;
}
.card--compact .card__title {
font-size: 18px;
}
<div class="card card--featured">
<img src="image.jpg" class="card__image" alt="">
<h3 class="card__title">Featured Article</h3>
<p class="card__description">This is a featured card.</p>
</div>
BEM’s strength is portability. It works with any framework, any build tool, or no build tool at all. The naming convention prevents conflicts—.card__title won’t clash with .profile__title even though both are “title” elements.
The weakness is verbosity and discipline. Class names get long: .navigation__submenu-item--active. Worse, nothing enforces the convention. A new developer can break the pattern, and you won’t know until conflicts appear.
Common pitfall: Nesting elements too deeply. Don’t create .block__element__subelement. Instead, treat the subelement as a new element: .block__subelement.
CSS Modules
CSS Modules solve the global namespace problem at build time. You write normal CSS, but class names are automatically scoped to the component. Under the hood, your build tool transforms .button into something like .Button_button__2x3kl.
Here’s how it works in a React component:
/* Button.module.css */
.button {
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.primary {
background: #0066cc;
color: white;
}
.secondary {
background: #f0f0f0;
color: #333;
}
.large {
padding: 16px 32px;
font-size: 18px;
}
// Button.jsx
import styles from './Button.module.css';
export function Button({ variant = 'primary', size, children }) {
const classNames = [
styles.button,
styles[variant],
size && styles[size]
].filter(Boolean).join(' ');
return <button className={classNames}>{children}</button>;
}
CSS Modules support composition, letting you build styles from other modules:
/* BaseButton.module.css */
.base {
padding: 12px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
}
/* IconButton.module.css */
.iconButton {
composes: base from './BaseButton.module.css';
display: inline-flex;
align-items: center;
gap: 8px;
}
For global styles (resets, utilities), use the :global selector:
:global(.container) {
max-width: 1200px;
margin: 0 auto;
}
/* Or wrap multiple rules */
:global {
.utility-class { /* ... */ }
.another-utility { /* ... */ }
}
TypeScript integration requires generating type definitions. With typescript-plugin-css-modules:
// Button.module.css.d.ts (auto-generated)
export const button: string;
export const primary: string;
export const secondary: string;
export const large: string;
CSS Modules hit a sweet spot: minimal learning curve, true scoping, and framework-agnostic (works with React, Vue, Angular). The generated class names prevent conflicts without changing how you write CSS. The main downside is less dynamic styling—you can’t easily pass props to change colors or sizes without pre-defining classes.
Styled Components (CSS-in-JS)
Styled Components brings CSS into JavaScript, using tagged template literals to define component styles. This enables truly dynamic styling based on props, state, or theme.
Basic usage:
import styled from 'styled-components';
const Button = styled.button`
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
background: ${props => props.primary ? '#0066cc' : '#f0f0f0'};
color: ${props => props.primary ? 'white' : '#333'};
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
// Usage
<Button primary>Primary Button</Button>
<Button>Secondary Button</Button>
Props enable powerful dynamic styling:
const Card = styled.div`
padding: ${props => props.compact ? '12px' : '24px'};
background: ${props => props.theme.background};
border-left: 4px solid ${props => {
switch(props.status) {
case 'success': return '#22c55e';
case 'error': return '#ef4444';
case 'warning': return '#f59e0b';
default: return '#e5e7eb';
}
}};
`;
Styled Components excel at theming through React context:
import { ThemeProvider } from 'styled-components';
const theme = {
colors: {
primary: '#0066cc',
secondary: '#666',
background: '#ffffff',
},
spacing: {
small: '8px',
medium: '16px',
large: '24px',
},
};
function App() {
return (
<ThemeProvider theme={theme}>
<YourApp />
</ThemeProvider>
);
}
// Components access theme automatically
const ThemedButton = styled.button`
background: ${props => props.theme.colors.primary};
padding: ${props => props.theme.spacing.medium};
`;
You can extend existing styled components:
const Button = styled.button`
padding: 12px 24px;
border: none;
border-radius: 4px;
`;
const PrimaryButton = styled(Button)`
background: #0066cc;
color: white;
`;
const IconButton = styled(Button)`
display: inline-flex;
align-items: center;
gap: 8px;
`;
The tradeoff: Styled Components add runtime overhead. Styles are generated and injected when components mount. For most apps this is negligible, but for performance-critical applications, the extra JavaScript execution and CSS injection can matter. Additionally, you’re locked into React (or other supported frameworks like Vue with similar libraries).
Comparison Matrix & Decision Guide
| Factor | BEM | CSS Modules | Styled Components |
|---|---|---|---|
| Learning Curve | Low (just naming) | Low (familiar CSS) | Medium (new syntax) |
| Bundle Size | Smallest | Small | Larger (+15-20kb) |
| Runtime Cost | None | None | Yes (style injection) |
| Dynamic Styling | Manual classes | Manual classes | Props-based |
| Framework Lock-in | None | None | React/Vue/etc |
| Tooling Required | None | Build tool | Build tool + runtime |
| TypeScript DX | N/A | Good (with plugin) | Excellent (built-in) |
Use BEM when:
- You need maximum portability (no build step, any framework)
- You’re working with server-rendered HTML
- Bundle size is critical
- Your team prefers separation of concerns
Use CSS Modules when:
- You want scoping without changing how you write CSS
- You’re migrating from traditional CSS
- You need framework flexibility
- You want compile-time optimization
Use Styled Components when:
- You’re already in the React ecosystem
- You need highly dynamic styling based on props/state
- You want component-level encapsulation
- Developer experience trumps bundle size concerns
Can they coexist? Absolutely. Many teams use CSS Modules for static layout components and Styled Components for dynamic, interactive elements. Or use BEM for a design system consumed across multiple frameworks, with CSS Modules in the main application.
Real-World Example: Building a Button Component
Here’s the same button with three variants, implemented each way:
BEM:
.button {
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.button--primary {
background: #0066cc;
color: white;
}
.button--secondary {
background: #f0f0f0;
color: #333;
}
.button--large {
padding: 16px 32px;
font-size: 18px;
}
function Button({ variant = 'primary', size, children }) {
const classes = ['button', `button--${variant}`, size && `button--${size}`]
.filter(Boolean)
.join(' ');
return <button className={classes}>{children}</button>;
}
CSS Modules:
/* Button.module.css */
.button { /* base styles */ }
.primary { background: #0066cc; color: white; }
.secondary { background: #f0f0f0; color: #333; }
.large { padding: 16px 32px; font-size: 18px; }
import styles from './Button.module.css';
function Button({ variant = 'primary', size, children }) {
const classes = [styles.button, styles[variant], size && styles[size]]
.filter(Boolean)
.join(' ');
return <button className={classes}>{children}</button>;
}
Styled Components:
const Button = styled.button`
padding: ${props => props.size === 'large' ? '16px 32px' : '12px 24px'};
font-size: ${props => props.size === 'large' ? '18px' : '16px'};
border: none;
border-radius: 4px;
cursor: pointer;
background: ${props => props.variant === 'primary' ? '#0066cc' : '#f0f0f0'};
color: ${props => props.variant === 'primary' ? 'white' : '#333'};
`;
// Usage: <Button variant="primary" size="large">Click me</Button>
Notice how BEM and CSS Modules look similar in React code—you’re just concatenating class names. Styled Components eliminates class name logic entirely, moving everything into the styled definition.
Conclusion & Recommendations
There’s no universal best choice. BEM offers maximum portability at the cost of verbosity. CSS Modules provide scoping with minimal learning curve. Styled Components enable powerful dynamic styling but add runtime cost and framework coupling.
For new projects, I recommend CSS Modules as the default. They solve the scoping problem without radical changes to your workflow, work with any framework, and have excellent performance. Reach for Styled Components when you need truly dynamic styling or are deep in the React ecosystem. Use BEM when you need to support environments without build tools or want maximum framework flexibility.
The best architecture is the one your team will actually follow. Choose based on your constraints: team experience, performance requirements, and framework choices. Then document your decision and enforce it through code review and linting.
Further resources:
- BEM official methodology: getbem.com
- CSS Modules spec: github.com/css-modules/css-modules
- Styled Components documentation: styled-components.com