Introduction
CSS-in-JS represents a paradigm shift in how we write styles for web applications. Instead of maintaining separate CSS files and coordinating class names between stylesheets and JavaScript components, CSS-in-JS libraries let you write styles directly in your JavaScript code. The styles are colocated with the components they style, scoped automatically, and can leverage the full power of JavaScript for dynamic styling.
Two libraries have dominated the CSS-in-JS landscape: styled-components and Emotion. Both take a similar approach — writing CSS as tagged template literals or object syntax within JavaScript — but differ in their APIs, performance characteristics, and ecosystem integration. Choosing between them (or choosing CSS-in-JS at all) is a significant architectural decision that affects every component in your application.
This guide provides a comprehensive comparison of styled-components and Emotion, with practical patterns for each, performance analysis, and guidance on when to use which approach.
Understanding CSS-in-JS: Core Concepts
How CSS-in-JS Works
CSS-in-JS libraries generate unique class names at runtime (or build time) and inject styles into the document. When you write a styled component, the library creates a React component that renders an HTML element with a generated class name, and injects the corresponding CSS into a <style> tag in the document head.
// Styled-components syntax
import styled from 'styled-components';
const Button = styled.button`
background: #2563eb;
color: white;
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
&:hover {
background: #1d4ed8;
}
`;
// Usage: <Button>Click me</Button>Styled-Components API
Styled-components uses tagged template literals to define styles. The styled function is called with an HTML element name or React component, and the template literal contains CSS:
import styled from 'styled-components';
const Card = styled.article`
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
`;
const CardTitle = styled.h3`
font-size: 1.25rem;
font-weight: 600;
padding: 16px;
`;
const CardImage = styled.img`
width: 100%;
height: 200px;
object-fit: cover;
`;Emotion API
Emotion offers two main APIs: css prop and the styled API. The css prop is unique to Emotion and provides a more flexible approach:
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import styled from '@emotion/styled';
// Styled API (similar to styled-components)
const Button = styled.button`
background: #2563eb;
color: white;
padding: 8px 16px;
border-radius: 6px;
border: none;
`;
// css prop API (unique to Emotion)
function Card({ children, variant }) {
return (
<div css={css`
border: 1px solid #e5e7eb;
border-radius: 12px;
${variant === 'highlighted' && css`
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
`}
`}>
{children}
</div>
);
}Architecture and Design Patterns
The Component Variant Pattern
Both libraries handle component variants well, but with slightly different syntaxes:
Styled-components:
const Badge = styled.span`
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
${({ variant }) => variant === 'success' && css`
background: #dcfce7;
color: #166534;
`}
${({ variant }) => variant === 'error' && css`
background: #fef2f2;
color: #991b1b;
`}
`;
// Usage: <Badge variant="success">Active</Badge>Emotion:
const badgeStyles = {
success: css`
background: #dcfce7;
color: #166534;
`,
error: css`
background: #fef2f2;
color: #991b1b;
`,
};
function Badge({ variant, children }) {
return (
<span css={css`
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
${badgeStyles[variant]}
`}>
{children}
</span>
);
}The Theme Provider Pattern
Both libraries support theming through a Provider component that makes theme values available to all styled components:
import { ThemeProvider } from 'styled-components'; // or @emotion/react
const theme = {
colors: {
primary: '#2563eb',
primaryHover: '#1d4ed8',
text: '#1f2937',
textMuted: '#6b7280',
surface: '#ffffff',
border: '#e5e7eb',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
radii: {
sm: '4px',
md: '8px',
lg: '12px',
},
};
function App() {
return (
<ThemeProvider theme={theme}>
<MyApp />
</ThemeProvider>
);
}Access theme values in styled-components:
const Button = styled.button`
background: ${({ theme }) => theme.colors.primary};
padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md};
border-radius: ${({ theme }) => theme.radii.md};
&:hover {
background: ${({ theme }) => theme.colors.primaryHover};
}
`;The Composition Pattern
Both libraries support composing styles — combining multiple style definitions:
Styled-components:
const baseButton = css`
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
border: none;
`;
const PrimaryButton = styled.button`
${baseButton}
background: #2563eb;
color: white;
`;
const OutlineButton = styled.button`
${baseButton}
background: transparent;
border: 1px solid #2563eb;
color: #2563eb;
`;Emotion:
const baseButton = css`
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
border: none;
`;
function PrimaryButton(props) {
return <button css={css`${baseButton}; background: #2563eb; color: white;`} {...props} />;
}
function OutlineButton(props) {
return <button css={css`${baseButton}; background: transparent; border: 1px solid #2563eb; color: #2563eb;`} {...props} />;
}Step-by-Step Implementation
Building a Complete Button System with Styled-Components
import styled, { css } from 'styled-components';
const sizes = {
sm: css`
padding: 4px 8px;
font-size: 12px;
`,
md: css`
padding: 8px 16px;
font-size: 14px;
`,
lg: css`
padding: 12px 24px;
font-size: 16px;
`,
};
const variants = {
primary: css`
background: ${({ theme }) => theme.colors.primary};
color: white;
&:hover { background: ${({ theme }) => theme.colors.primaryHover}; }
`,
outline: css`
background: transparent;
border: 1px solid ${({ theme }) => theme.colors.primary};
color: ${({ theme }) => theme.colors.primary};
&:hover { background: ${({ theme }) => theme.colors.primary}; color: white; }
`,
ghost: css`
background: transparent;
color: ${({ theme }) => theme.colors.text};
&:hover { background: ${({ theme }) => theme.colors.border}; }
`,
};
const Button = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: ${({ theme }) => theme.radii.md};
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
${({ size = 'md' }) => sizes[size]}
${({ variant = 'primary' }) => variants[variant]}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
${({ fullWidth }) => fullWidth && css`
width: 100%;
`}
`;
// Usage
function App() {
return (
<>
<Button size="sm" variant="primary">Small Primary</Button>
<Button size="md" variant="outline">Medium Outline</Button>
<Button size="lg" variant="ghost">Large Ghost</Button>
<Button fullWidth variant="primary">Full Width</Button>
</>
);
}Building a Responsive Layout with Emotion
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import styled from '@emotion/styled';
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 0 ${({ theme }) => theme.spacing.md};
@media (min-width: 768px) {
padding: 0 ${({ theme }) => theme.spacing.lg};
}
`;
const Grid = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing.md};
@media (min-width: 640px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
gap: ${({ theme }) => theme.spacing.lg};
}
`;
const Card = styled.article`
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: ${({ theme }) => theme.radii.lg};
overflow: hidden;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
`;
const CardImage = styled.img`
width: 100%;
height: 200px;
object-fit: cover;
`;
const CardBody = styled.div`
padding: ${({ theme }) => theme.spacing.md};
`;
const CardTitle = styled.h3`
font-size: 1.125rem;
font-weight: 600;
margin-bottom: ${({ theme }) => theme.spacing.xs};
color: ${({ theme }) => theme.colors.text};
`;
const CardText = styled.p`
color: ${({ theme }) => theme.colors.textMuted};
font-size: 0.875rem;
line-height: 1.5;
`;Building a Form Component with Emotion's css Prop
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import { useState } from 'react';
const formGroupStyles = css`
margin-bottom: 16px;
`;
const labelStyles = css`
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
color: #374151;
`;
const inputBaseStyles = css`
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.15s, box-shadow 0.15s;
&:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
`;
const errorStyles = css`
border-color: #ef4444;
&:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
`;
function FormField({ label, error, ...inputProps }) {
return (
<div css={formGroupStyles}>
<label css={labelStyles}>{label}</label>
<input
css={[inputBaseStyles, error && errorStyles]}
{...inputProps}
/>
{error && (
<span css={css`color: #ef4444; font-size: 12px; margin-top: 4px;`}>
{error}
</span>
)}
</div>
);
}Real-World Use Cases
Use Case 1: Design System Component Library
CSS-in-JS excels for building design systems because it provides scoped styles, theme integration, and dynamic variants without external stylesheet coordination. A button component with 5 variants and 3 sizes would require 15 CSS classes with traditional CSS, but a single styled component with props handles all combinations.
Use Case 2: White-Label Applications
Applications that serve multiple brands benefit from CSS-in-JS theming. Switching brands means swapping the theme object — all components automatically update their colors, spacing, and typography. This is significantly simpler than maintaining multiple CSS files or using CSS variable overrides.
Use Case 3: Dynamic Widget Systems
Dashboard applications with user-configurable widgets benefit from CSS-in-JS for dynamic styling. Widget backgrounds, borders, and layouts can be configured by the user and applied directly as props, without generating a combinatorial explosion of CSS classes.
Use Case 4: Server-Rendered Applications
Both libraries support server-side rendering (SSR), injecting critical CSS into the HTML response. This eliminates the flash of unstyled content (FOUC) that can occur with traditional CSS files and ensures the initial render includes all necessary styles.
Best Practices for Production
-
Use the
csshelper for reusable style blocks — Both libraries support acsshelper that creates reusable style blocks. Use this for shared patterns like button bases, input styles, and common layout rules. -
Prefer the styled API for component styles — The
styled.divpattern creates clear, self-documenting component names that appear in DevTools. Use thecssprop for one-off or conditional styles. -
Avoid inline style objects — While Emotion supports object syntax (
css={{ color: 'red' }}), template literals are generally more readable for complex styles and support pseudo-selectors, media queries, and nesting natively. -
Use theme tokens, not hardcoded values — Always reference theme values for colors, spacing, and typography. This enables theming and ensures design consistency:
// Bad
const Title = styled.h1` color: #1f2937; `;
// Good
const Title = styled.h1` color: ${({ theme }) => theme.colors.text}; `;- Minimize runtime overhead with
shouldForwardProp— Prevent styled-component props from being forwarded to the DOM:
const Card = styled('div').withConfig({
shouldForwardProp: (prop) => !['variant', 'highlighted'].includes(prop),
})`
border: 1px solid ${({ highlighted }) => highlighted ? '#2563eb' : '#e5e7eb'};
`;-
Consider Server Components implications — React Server Components don't support CSS-in-JS libraries that use runtime injection. For Next.js App Router, consider using the
'use client'directive or adopting build-time CSS-in-JS solutions. -
Use TypeScript for theme typing — Define a theme interface to get autocomplete and type checking for theme values:
interface Theme {
colors: {
primary: string;
primaryHover: string;
text: string;
// ...
};
spacing: {
xs: string;
sm: string;
md: string;
// ...
};
}- Profile CSS-in-JS bundle size — styled-components adds approximately 12KB (gzipped) and Emotion's core is approximately 7KB. For performance-critical applications, consider build-time CSS-in-JS alternatives that extract styles to static CSS.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Creating styled components inside render functions | New component identity each render, causing style recalculation and remounts | Define styled components outside the render function or at module scope |
| Passing unknown props to DOM elements | React console warnings | Use shouldForwardProp or transient props ($variant) |
| No SSR support configured | Flash of unstyled content (FOUC) | Configure the SSR plugin for your framework (Next.js, Gatsby, etc.) |
| Excessive dynamic styles from props | Runtime CSS generation on every prop change | Use CSS custom properties for frequently changing values |
| Theme object recreated on every render | Unnecessary re-renders of all themed components | Define theme outside the component or memoize it |
| Mixing CSS-in-JS with CSS modules | Inconsistent specificity and class name collisions | Choose one approach per project and stick with it |
Performance Optimization
CSS-in-JS has inherent runtime overhead compared to static CSS. Both styled-components and Emotion parse template literals, generate CSS strings, create unique class names, and inject styles into the DOM at runtime. For most applications, this overhead is negligible, but there are scenarios where it matters.
Static extraction is the most impactful optimization. Libraries like Linaria and vanilla-extract provide CSS-in-JS developer experience with zero runtime overhead by extracting styles at build time. For styled-components and Emotion, avoid dynamic styles that change frequently:
// Bad: generates new CSS on every scroll position change
const ScrollElement = styled.div`
transform: translateY(${props => props.scrollY}px);
`;
// Good: use CSS custom properties for frequent updates
const ScrollElement = styled.div`
transform: translateY(var(--scroll-y, 0px));
`;
// Then update via JavaScript:
element.style.setProperty('--scroll-y', `${scrollY}px`);Server-side rendering performance is another consideration. Both libraries collect styles during SSR and inject them into the HTML. For large applications with many styled components, this adds to the HTML payload size. Use critical CSS extraction to only include styles needed for the initial viewport.
Comparison with Alternatives
| Feature | Styled-Components | Emotion | CSS Modules | Tailwind CSS | Vanilla Extract |
|---|---|---|---|---|---|
| Scoping | Automatic (generated classes) | Automatic | Automatic (module classes) | Utility classes | Automatic |
| Dynamic styles | Yes (props) | Yes (props + css prop) | No (requires JS) | No (requires JS) | Limited |
| Theming | Built-in (ThemeProvider) | Built-in (ThemeProvider) | CSS custom properties | Config-based | CSS custom properties |
| Runtime overhead | ~12KB + CSS generation | ~7KB + CSS generation | Zero | Zero (with purge) | Zero |
| SSR support | Yes (plugin required) | Yes (built-in) | Yes | Yes | Yes |
| TypeScript support | Excellent | Excellent | Excellent | Good (with plugin) | Excellent |
| DevTools | Component names in classes | Component names in classes | Original class names | Utility classes | Original names |
| Server Components | Requires client boundary | Requires client boundary | Full support | Full support | Full support |
Advanced Patterns
Transient Props in Styled-Components
Use the $ prefix for props that should only be consumed by styled-components and not forwarded to the DOM:
const Button = styled.button`
background: ${({ $variant }) => $variant === 'primary' ? '#2563eb' : '#6b7280'};
`;
// $variant is not forwarded to the DOM
<Button $variant="primary">Click</Button>Global Styles
Both libraries provide mechanisms for global styles:
// Styled-components
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: ${({ theme }) => theme.colors.text};
background: ${({ theme }) => theme.colors.surface};
}
`;
// Emotion
import { Global, css } from '@emotion/react';
function GlobalStyles() {
return (
<Global styles={css`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
`} />
);
}Keyframe Animations
Both libraries support CSS keyframe animations:
// Styled-components
import styled, { keyframes } from 'styled-components';
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
`;
const AnimatedCard = styled(Card)`
animation: fadeIn 0.3s ease-out;
`;Style Composition with Emotion
Emotion's css function returns a serializable style object that can be composed:
const base = css`
padding: 8px 16px;
border-radius: 6px;
`;
const primary = css`
${base}
background: #2563eb;
color: white;
`;
function PrimaryButton(props) {
return <button css={primary} {...props} />;
}Testing Strategies
CSS-in-JS components should be tested for both rendering and style correctness:
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Button } from './Button';
const theme = {
colors: { primary: '#2563eb', primaryHover: '#1d4ed8' },
spacing: { sm: '8px', md: '16px' },
radii: { md: '8px' },
};
const renderWithTheme = (ui) =>
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
test('button renders with correct background', () => {
renderWithTheme(<Button variant="primary">Click</Button>);
const button = screen.getByRole('button');
expect(button).toHaveStyleRule('background', '#2563eb');
});
test('button is disabled when disabled prop is true', () => {
renderWithTheme(<Button disabled>Click</Button>);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveStyleRule('opacity', '0.5');
});Future Outlook
The CSS-in-JS landscape is evolving rapidly. React Server Components have introduced challenges for runtime CSS-in-JS libraries, as they cannot inject styles during server rendering. This has driven interest in build-time CSS-in-JS solutions like vanilla-extract, Linaria, and Panda CSS that extract styles to static CSS files at build time while maintaining the developer experience of CSS-in-JS.
Zero-runtime approaches are gaining adoption because they combine the best of both worlds: colocated styles with TypeScript support, theme integration, and zero runtime overhead. Libraries like Panda CSS and StyleX (from Meta) represent this new generation.
Conclusion
CSS-in-JS offers significant developer experience benefits through scoped styles, dynamic theming, and component colocation. The key takeaways are:
- styled-components is best for teams that prefer tagged template literals and need a mature ecosystem with extensive documentation and community resources.
- Emotion is best for teams that want flexibility (css prop + styled API), smaller bundle size, and better framework integration.
- Both libraries provide similar capabilities for theming, variants, and SSR. Choose based on API preference and ecosystem needs.
- Consider zero-runtime alternatives (vanilla-extract, Panda CSS) for new projects, especially with React Server Components.
- Always use theme tokens and avoid hardcoded values to maintain design consistency and enable theming.
Start by evaluating whether your project benefits from CSS-in-JS at all. For component libraries, design systems, and heavily themed applications, CSS-in-JS provides clear advantages. For simpler projects, CSS Modules or utility-first CSS may be sufficient with less overhead.