styled-components
styled-components lets you write real CSS inside tagged template literals and get back a React component with a generated, collision-proof class name. styled.button`...` produces a button; styled(Link)`...` wraps any component that accepts a className. Interpolated functions receive the component's props and the current theme, so a style can change based on a prop without you touching class name strings. It ships createGlobalStyle for resets, keyframes for scoped animations, ThemeProvider for design tokens over React context, ServerStyleSheet for server rendering, and a React Native build with the same API. Version 6 rewrote the internals in TypeScript so types come in the box, and it no longer guesses which props are style-only: you prefix those with a dollar sign to keep them off the DOM.
Still the most pleasant way to write styles when they actually depend on runtime props, and v6 fixed the TypeScript story. On a React Server Components stack, or anywhere the render-time cost shows up in a profile, pick a zero-runtime option instead.
Use it if
- You want component-scoped CSS with no build step, no bundler plugin, and no separate .css files to keep in sync with your components
- Your styles genuinely depend on runtime values (a chart bar width from data, a theme the user picks at runtime, an animation driven by state) rather than a fixed set of variants
- You share a component library across React DOM and React Native and want one styling API for both
- You are already on styled-components v5 and want the TypeScript-native rewrite, faster stylis 4 compiler, and the new createTheme CSS-variable bridge without changing your mental model
- You need to style third-party components (react-router Link, a design system button) and would rather wrap them than fight their class names
- You are building on the Next.js App Router or React Server Components: every styled component forces a client boundary, and you need a hand-written registry with useServerInsertedHTML to avoid a flash of unstyled content, which is friction Tailwind and CSS Modules simply do not have
- Runtime cost matters: styles are serialized, hashed, and injected during render, so long lists and frequently re-rendering trees pay a per-render price that zero-runtime tools like vanilla-extract or plain CSS Modules do not
- You care about bundle weight on a small site: about 13.4 KB gzipped plus stylis and @emotion/is-prop-valid ships to every user, and none of it tree-shakes away
- You are on v5 and expected a free upgrade: v6 dropped automatic prop filtering, so every style-only prop needs a dollar-sign prefix or React logs unknown-attribute warnings across your app, and vendor prefixes became opt-in via enableVendorPrefixes
- Bus factor worries you: the README states the project is largely maintained by one person and asks for Open Collective funding, and v7 has been sitting in prerelease while 6.x gets the backports
- Your styling is really just a design system with fixed variants: Tailwind or CSS Modules express that with zero runtime and better editor tooling
Setup reality
npm install styled-components and it works in a plain Vite or CRA React app with no config and no @types package, because v6 ships its own TypeScript definitions. The peer range is React 16.8 or newer, react-dom and react-native are both optional peers, and Node 16 is the floor. Two things cost real time. First, server rendering: with the Next.js Pages Router you need babel-plugin-styled-components or the compiler flag plus a _document that flushes ServerStyleSheet, and with the App Router you have to write a client registry component that calls useServerInsertedHTML, mark it 'use client', and wrap your layout in it, or users see unstyled HTML on first paint. Second, theme typing: props.theme is an empty DefaultTheme until you add a styled.d.ts that declares module 'styled-components' and extends DefaultTheme with your token shape, and until you do that every theme access is a type error or an any. Vendor prefixes are off by default in v6, so if you support older Safari you add enableVendorPrefixes on a StyleSheetManager at the root.
Patterns
Style an element and vary it by propstyled-element-with-props
import styled from 'styled-components';
const Button = styled.button<{ $primary?: boolean }>`
padding: 0.5rem 1.25rem;
border-radius: 6px;
border: 1px solid palevioletred;
color: ${props => (props.$primary ? 'white' : 'palevioletred')};
background: ${props => (props.$primary ? 'palevioletred' : 'transparent')};
`;
<Button>Cancel</Button>
<Button $primary>Save</Button>The dollar-sign prefix marks a transient prop. Without it, v6 forwards `primary` to the real <button> and React warns about a non-boolean attribute in the console on every render.
Extend a styled component and style a third-party oneextend-and-wrap
const DangerButton = styled(Button)`
border-color: tomato;
color: tomato;
`;
import { Link } from 'react-router-dom';
const NavLink = styled(Link)`
color: inherit;
text-decoration: none;
&:hover { text-decoration: underline; }
`;styled(X) only works if X spreads a className prop onto a DOM node. Wrapping a component that swallows className produces a class that is generated and never applied, which looks like the CSS silently failing.
Make props.theme typed instead of emptytype-the-theme
// styled.d.ts
import 'styled-components';
declare module 'styled-components' {
export interface DefaultTheme {
colors: { bg: string; fg: string; accent: string };
space: (n: number) => string;
}
}
// anywhere
const Card = styled.div`
background: ${p => p.theme.colors.bg};
padding: ${p => p.theme.space(2)};
`;This file must be included by tsconfig (inside your src include glob) or the module augmentation never loads and theme stays an empty interface. v6 needs no @types/styled-components; installing it will conflict.
Provide a theme and read it outside a templatetheme-provider-and-hook
import styled, { ThemeProvider, useTheme } from 'styled-components';
const dark = { colors: { bg: '#111', fg: '#eee', accent: '#7aa2f7' }, space: (n: number) => `${n * 4}px` };
function Chart() {
const theme = useTheme();
return <svg><line stroke={theme.colors.accent} /></svg>;
}
<ThemeProvider theme={dark}>
<Chart />
</ThemeProvider>useTheme returns undefined outside a ThemeProvider, so a component used in both contexts needs a fallback. For class components use the withTheme higher-order component instead.
Bridge a theme to CSS custom properties with createThemecss-variable-theme
import styled, { createTheme, ThemeProvider } from 'styled-components';
const theme = createTheme({
colors: { bg: '#ffffff', fg: '#111111' },
space: { md: '1rem' },
});
const Card = styled.div`
background: ${theme.colors.bg}; /* var(--sc-colors-bg, #ffffff) */
color: ${theme.colors.fg};
padding: ${theme.space.md};
`;
<ThemeProvider theme={theme.raw}>
<theme.GlobalStyle />
<Card>Token-driven</Card>
</ThemeProvider>The returned object is the theme with var() strings at every leaf; pass theme.raw to ThemeProvider and mount theme.GlobalStyle once. Do not do JS arithmetic on a leaf, it is a string like 'var(--sc-space-md, 1rem)'; use calc() instead.
Share a reusable style fragmentshared-css-block
import styled, { css } from 'styled-components';
const truncate = css`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const Title = styled.h3<{ $clamped?: boolean }>`
font-size: 1.125rem;
${p => p.$clamped && truncate}
max-width: 24ch;
`;Use css`` rather than a plain template string whenever the fragment contains interpolations; a raw string turns functions into '[object Object]' in the output CSS.
Define scoped keyframeskeyframes-animation
import styled, { keyframes } from 'styled-components';
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const Spinner = styled.div`
width: 24px;
height: 24px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: ${spin} 0.8s linear infinite;
`;Interpolate the keyframes object, never its name as a string. Inside createGlobalStyle a keyframes object needs the css helper around the rule, otherwise the animation name is not injected.
Bake in default attributes and derived propsdefault-attrs
const PasswordInput = styled.input.attrs({
type: 'password',
autoComplete: 'current-password',
})`
border: 1px solid #ccc;
padding: 0.5em;
`;
const Meter = styled.div.attrs<{ $value: number }>(p => ({
role: 'progressbar',
'aria-valuenow': p.$value,
style: { width: `${p.$value}%` },
}))`
height: 8px;
background: palevioletred;
`;Anything that changes on nearly every render belongs in the inline `style` object as shown, not in the template. A distinct interpolated value generates a new class name and a new injected rule each time, which grows the stylesheet without bound.
Inject a reset and app-wide rulesglobal-styles
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
background: ${p => p.theme.colors.bg};
}
`;
// render once, near the root
<ThemeProvider theme={dark}>
<GlobalStyle />
<App />
</ThemeProvider>Render exactly one instance. Mounting the same GlobalStyle in two places injects the rules twice, and unmounting one of them removes them for both.
Server-render styles in the Next.js App Routernext-app-router-registry
'use client';
import { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export function StyledRegistry({ children }: { children: React.ReactNode }) {
const [sheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = sheet.getStyleElement();
sheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
return <StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>;
}Wrap children in app/layout.tsx with this, and set compiler.styledComponents in next.config so class names match between server and client. Skip it and the first paint is unstyled HTML.
Keep unknown props off the DOM without renaming everythingfilter-props-globally
import isPropValid from '@emotion/is-prop-valid';
import { StyleSheetManager } from 'styled-components';
<StyleSheetManager
shouldForwardProp={(prop, elementToBeCreated) =>
typeof elementToBeCreated === 'string' ? isPropValid(prop) : true
}
>
<App />
</StyleSheetManager>This is the v5 compatibility escape hatch when a v6 migration would mean renaming hundreds of props. Component-level styled.div.withConfig({ shouldForwardProp }) overrides this, and the check runs on every prop of every styled render.
Render the same styles as a different elementpolymorphic-as
const Button = styled.button`
padding: 0.5rem 1rem;
border-radius: 6px;
`;
<Button as="a" href="/pricing">See pricing</Button>
// lock it in permanently instead of per call site
const LinkButton = styled(Button).attrs({ as: 'a' })``;TypeScript infers the new element's props from `as`, so href type-checks here. Swapping a button for an anchor changes keyboard and screen reader behavior, so add role or type attributes where the semantics no longer match.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @emotion/styled | npm | You want nearly the same API with a smaller core and the css prop, and you do not need the React Native target |
| @vanilla-extract/css | npm | You want TypeScript-authored styles compiled to static CSS at build time with zero runtime |
| tailwindcss | npm | Your styles are a fixed design system rather than runtime-computed, and you want no JS shipped for styling at all |