Styling React with Emotion: css Prop vs styled, Theming, SSR and How It Compares to Tailwind
Key takeaways
Emotion lets you write CSS directly in JavaScript with full TypeScript support, theming, and dynamic styles. This guide covers both the css prop and styled API, plus theming, SSR, and when to use Emotion vs Tailwind CSS.
Why Emotion?
Emotion lets you colocate styles with components, use JavaScript variables in CSS, and get TypeScript autocomplete for theme values:
// Before: separate CSS file
// styles.module.css → .button { ... }
// component.tsx → className="button"
// After: Emotion
const Button = styled.button`
background: ${theme.colors.primary};
padding: ${theme.spacing(2)};
&:hover { background: ${theme.colors.primaryDark}; }
`
The core value proposition here isn’t just “CSS written inline” — it’s that these styles are genuine JavaScript template literals, which means they can reference any variable, function, or prop in scope, something a plain .css file fundamentally can’t do. A separate CSS file can’t read a component’s theme object, can’t branch on a variant prop, and can’t get TypeScript to catch a typo’d color token — Emotion’s styles are real code that runs, checked by the same type system as the rest of the component, which is the mechanism behind the theming and dynamic-styling patterns covered later in this guide.
Installation
# Core packages
npm install @emotion/react @emotion/styled
# Optional: babel plugin for css prop (without pragma comment)
npm install --save-dev @emotion/babel-plugin
The babel plugin is optional specifically because there’s a lower-friction alternative — the /** @jsxImportSource @emotion/react */ pragma comment shown in the css prop example below — but the plugin is worth installing on any real project regardless: without it, the css prop only works with that per-file pragma comment (easy to forget on a new file, and it changes how JSX itself compiles for that file), while the babel plugin enables the css prop project-wide with no per-file boilerplate and also adds automatic component labeling for clearer class names in dev tools.
Two APIs
Emotion has two main APIs:
@emotion/styled — Like styled-components
import styled from '@emotion/styled'
const Button = styled.button`
background: cornflowerblue;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
&:hover {
background: royalblue;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
// Usage
<Button onClick={handleClick}>Click Me</Button>
<Button disabled>Disabled</Button>
styled.button creates an actual new React component — not a style applied to an existing element, but a genuinely distinct component with its own identity that happens to render a styled <button>. This matters practically: it’s what lets <Button> be used exactly like any other component (composed, extended via styled(Button), passed as a prop to something expecting a component type), and it’s why the “extend another styled component” pattern in the Composition section later works — you’re subclassing a real component, not just concatenating CSS strings.
@emotion/react — css prop
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react'
const buttonStyles = css`
background: cornflowerblue;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
`
// css prop works on any JSX element
function App() {
return (
<button css={buttonStyles}>Click Me</button>
)
}
The css prop’s advantage over styled isn’t capability, it’s ergonomics for the specific case of styling an element you don’t need a reusable, named component for — applying styles to a one-off <div> in a layout, or conditionally styling a specific instance of an existing component, doesn’t require inventing and naming a new styled component just to hold a few style rules. The tradeoff (covered in the FAQ above) is that css requires either the pragma comment or the babel plugin to work at all, since css as a JSX prop isn’t valid React without one of those two rewriting it into an actual className behind the scenes — styled, by contrast, works with zero extra build configuration since it’s just a normal function call, not new JSX syntax.
Dynamic Styles
import styled from '@emotion/styled'
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
fullWidth?: boolean
}
const Button = styled.button<ButtonProps>`
border: none;
border-radius: 4px;
cursor: pointer;
width: ${({ fullWidth }) => fullWidth ? '100%' : 'auto'};
/* Variant styles */
${({ variant = 'primary' }) => {
const variants = {
primary: `background: cornflowerblue; color: white;`,
secondary: `background: transparent; color: cornflowerblue; border: 1px solid currentColor;`,
danger: `background: #e53e3e; color: white;`,
}
return variants[variant]
}}
/* Size styles */
${({ size = 'md' }) => {
const sizes = {
sm: `padding: 4px 8px; font-size: 12px;`,
md: `padding: 8px 16px; font-size: 14px;`,
lg: `padding: 12px 24px; font-size: 16px;`,
}
return sizes[size]
}}
`
// Usage
<Button variant="primary" size="lg">Submit</Button>
<Button variant="danger" fullWidth>Delete Account</Button>
Each interpolated function inside the template literal (${({ variant = 'primary' }) => {...}}) receives the component’s actual props at render time and returns a CSS string, which Emotion splices into the final stylesheet — this is the mechanism, not magic, behind “styles that react to props”: every prop-dependent block here is a plain function call evaluated fresh on each render, which is also why giving props sensible defaults (variant = 'primary') matters, the same way it would in any other destructured function parameter — without it, an unset variant would throw trying to index variants[undefined].
Theming
import { ThemeProvider, useTheme } from '@emotion/react'
import styled from '@emotion/styled'
// Define theme type
declare module '@emotion/react' {
export interface Theme {
colors: {
primary: string
primaryDark: string
background: string
text: string
border: string
}
spacing: (n: number) => string
borderRadius: string
shadows: {
sm: string
md: string
}
}
}
// This module augmentation is what earns Emotion its "excellent
// TypeScript" reputation from the FAQ above: without it, ({ theme }) =>
// theme.colors.primary type-checks theme as an untyped, effectively-any
// object, and a typo like theme.colors.primry would silently compile.
// Declaring the shape of Theme here (once, project-wide) means every
// styled-component's theme callback gets full autocomplete and compile-time
// checking against the real theme shape, everywhere it's used.
const lightTheme = {
colors: {
primary: '#4A90E2',
primaryDark: '#357ABD',
background: '#ffffff',
text: '#1a1a1a',
border: '#e2e8f0',
},
spacing: (n: number) => `${n * 8}px`,
borderRadius: '6px',
shadows: {
sm: '0 1px 3px rgba(0,0,0,0.12)',
md: '0 4px 6px rgba(0,0,0,0.1)',
},
}
const darkTheme = {
...lightTheme,
colors: {
primary: '#63B3ED',
primaryDark: '#4299E1',
background: '#1a202c',
text: '#e2e8f0',
border: '#2d3748',
},
}
// Note darkTheme spreads lightTheme (...lightTheme) and only overrides
// colors — spacing, borderRadius, and shadows stay identical across both
// themes. This isn't laziness, it's usually the right default: most
// design tokens (spacing scale, border radius, shadow depth) don't
// actually need to change between light and dark mode, only color does —
// spreading and overriding just the differing keys keeps the two themes
// from drifting out of sync on the values that should stay shared.
// Theme-aware styled component
const Card = styled.div`
background: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: ${({ theme }) => theme.borderRadius};
padding: ${({ theme }) => theme.spacing(3)};
box-shadow: ${({ theme }) => theme.shadows.sm};
`
const PrimaryButton = styled.button`
background: ${({ theme }) => theme.colors.primary};
color: white;
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
border: none;
border-radius: ${({ theme }) => theme.borderRadius};
&:hover {
background: ${({ theme }) => theme.colors.primaryDark};
}
`
// Notice neither Card nor PrimaryButton ever explicitly receives a theme
// prop from their parent — theme is injected automatically into every
// styled component's props by ThemeProvider (below) via React Context,
// which is what makes ({ theme }) => ... work without threading a theme
// prop through every single component in the tree by hand.
// useTheme hook in function components
function Header() {
const theme = useTheme()
return (
<header css={{ background: theme.colors.primary, padding: theme.spacing(2) }}>
Logo
</header>
)
}
// App root
function App() {
const [isDark, setIsDark] = useState(false)
return (
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
<Card>
<PrimaryButton onClick={() => setIsDark(!isDark)}>
Toggle Theme
</PrimaryButton>
</Card>
</ThemeProvider>
)
}
useTheme() is the escape hatch for exactly one situation styled’s automatic injection doesn’t cover: needing theme values outside a styled template literal, such as inline in the css prop’s object syntax (as shown in Header here) or in plain JavaScript logic that decides something based on theme colors. The dark-mode toggle at the bottom demonstrates the entire payoff of this architecture in one line — swapping isDark ? darkTheme : lightTheme on the single ThemeProvider at the root re-themes every styled component and every useTheme() call anywhere beneath it simultaneously, with no per-component logic needed to react to the mode change.
Composition
import { css } from '@emotion/react'
import styled from '@emotion/styled'
// Reusable style fragments
const flexCenter = css`
display: flex;
align-items: center;
justify-content: center;
`
const truncate = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
// Compose multiple styles
const Card = styled.div`
${flexCenter}
padding: 16px;
border-radius: 8px;
`
// Interpolating flexCenter (a css`` fragment) directly into another
// template literal is how reusable style snippets compose in Emotion —
// this is a genuine language-level string/value interpolation, the same
// mechanism the theme functions above use, not a special "mixin" API to
// learn separately. Any css`` result can be dropped into any other
// styled/css template this way.
// Extend another styled component
const HighlightedCard = styled(Card)`
border: 2px solid cornflowerblue;
background: rgba(100, 149, 237, 0.1);
`
// styled(Card) generates a new component that renders Card with the extra
// class name's styles merged in via CSS specificity/ordering — it's not
// copying Card's styles textually, it's genuine composition at the
// component level. This is meaningfully different from, and generally
// preferable to, duplicating Card's CSS rules into a new component: a
// later change to Card's base styles automatically propagates to
// HighlightedCard too, since HighlightedCard is still rendering the real
// Card underneath, just with additional rules layered on.
// Compose with css prop
function UserName({ children }: { children: string }) {
return (
<span css={[truncate, { maxWidth: '200px', display: 'block' }]}>
{children}
</span>
)
}
The array syntax (css={[truncate, { maxWidth: '200px', ... }]}) is css prop’s own composition mechanism, distinct from the template-literal interpolation used above — it merges multiple style sources (a css`` fragment plus a plain object) in array order, with later entries able to override earlier ones for the same property. This is genuinely useful for mixing a shared, reusable fragment (truncate`) with one-off, instance-specific overrides without needing to interpolate one into the other as a string.
Keyframe Animations
import { keyframes } from '@emotion/react'
import styled from '@emotion/styled'
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
`
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`
const pulse = keyframes`
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
`
const FadeInDiv = styled.div`
animation: ${fadeIn} 0.3s ease-out;
`
const Spinner = styled.div`
width: 24px;
height: 24px;
border: 2px solid #e2e8f0;
border-top-color: cornflowerblue;
border-radius: 50%;
animation: ${spin} 0.8s linear infinite;
`
const PulseButton = styled.button`
animation: ${pulse} 2s ease-in-out infinite;
`
keyframes returns a value (a generated, scoped animation name) that gets interpolated into animation: ${fadeIn} ... the same way a color or spacing value would — this is why Emotion can offer genuinely scoped animations where a plain CSS @keyframes fadeIn { ... } risks name collisions between unrelated components that both happened to name an animation fadeIn. Each keyframes call generates a unique, hashed animation name under the hood, so two different components can both define their own fadeIn without ever conflicting, unlike raw global CSS keyframe names.
Global Styles
import { Global, css } from '@emotion/react'
const globalStyles = css`
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
margin: 0 0 1rem;
line-height: 1.2;
}
a {
color: cornflowerblue;
text-decoration: none;
&:hover { text-decoration: underline; }
}
`
function App() {
return (
<>
<Global styles={globalStyles} />
{/* rest of app */}
</>
)
}
Global exists as its own dedicated component precisely because scoped, per-component styles (everything covered so far) are Emotion’s default and can’t express things like CSS resets, body styling, or * selectors — those inherently need to reach outside any single component’s boundary. Reaching for Global should stay the exception rather than the rule in an Emotion codebase: the whole point of colocated, scoped component styles is avoiding the specificity and cascade problems global CSS is prone to, so Global is meant for the genuinely small set of styles (resets, base typography, box-sizing) that are legitimately app-wide by nature, not as a general-purpose styling escape hatch.
Server-Side Rendering (Next.js)
// pages/_document.tsx (Pages Router)
import createEmotionServer from '@emotion/server/create-instance'
import createCache from '@emotion/cache'
import Document, { Html, Head, Main, NextScript } from 'next/document'
export default function MyDocument({ emotionStyleTags }) {
return (
<Html>
<Head>{emotionStyleTags}</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
MyDocument.getInitialProps = async (ctx) => {
const cache = createCache({ key: 'css' })
const { extractCriticalToChunks } = createEmotionServer(cache)
const initialProps = await Document.getInitialProps(ctx)
const emotionStyles = extractCriticalToChunks(initialProps.html)
const emotionStyleTags = emotionStyles.styles.map(style => (
<style
data-emotion={`${style.key} ${style.ids.join(' ')}`}
key={style.key}
dangerouslySetInnerHTML={{ __html: style.css }}
/>
))
return { ...initialProps, emotionStyleTags }
}
This setup exists to solve a real problem specific to CSS-in-JS libraries under SSR: since Emotion generates styles at runtime as components render, a naive server-rendered page would send HTML with no matching <style> tags yet (they’d only be injected client-side after hydration), producing a visible flash of unstyled content (FOUC) as styles pop in after the page is already visible. extractCriticalToChunks solves this by tracking exactly which styles were actually used while rendering the page on the server, then emotionStyleTags embeds precisely those styles inline in the initial HTML response — the browser paints correctly-styled content on the very first render, with no gap where unstyled markup is momentarily visible. This Pages Router _document.tsx setup is also worth flagging as version-specific: the App Router (mentioned in the FAQ above) handles this differently, requiring 'use client' boundaries around Emotion usage and a separate registry-component pattern rather than _document.tsx, since the App Router’s rendering model doesn’t have a direct equivalent to the Pages Router’s custom Document.
Emotion vs Tailwind CSS vs styled-components
| Emotion | Tailwind CSS | styled-components | |
|---|---|---|---|
| Bundle | Small (runtime) | Zero runtime | Similar to Emotion |
| Dynamic styles | Easy | Limited (JIT) | Easy |
| Theming | Built-in | Config-based | Built-in |
| TypeScript | Excellent | Good | Good |
| Learning curve | Low (CSS syntax) | Medium (utility classes) | Low |
| Performance | Good | Best (no runtime) | Good |
| SSR | Supported | No issue | Supported |
| Best for | Design systems, theming | Rapid UI, utility-first | Component libraries |
Related Articles
- React Hooks Pitfalls: Stale Closures, Effect Loops, Fetch Races and Useless Memoization
- Next.js Performance: Measure First, Then Images, Bundle Splitting, Caching and Core Web Vitals
- What TypeScript 5 Changed