When I first picked up CSS-in-JS libraries like Styled Components and Emotion, what sold me was the tight feedback loop—drop a prop into a template literal and watch the UI respond. But as our apps and teams scaled, the downsides surfaced: larger bundles, slower HMR, and tangled style logic strewn across .js files.
Today, with Tailwind CSS v4 (and React Server Components shipping everywhere), I’ve switched back to author-time CSS. Tailwind’s utility-first mindset, its compile-time JIT engine, and its growing ecosystem mean we can keep reactive styling declarative—without shipping a CSS-in-JS runtime at all.
Below I’ll re-create the two most common kinds of dynamic styling challenges—values and states—but the Tailwind way.
Already sold? Skip to the code samples.1 · Values
A value is a single CSS property that changes at runtime—think background-color or width.
1.1 Inline styles (the old way)
function Button({ color, children }) {
return (
<button
className="rounded px-3 py-2 text-sm text-gray-50"
style={{ backgroundColor: color }}
>
{children}
</button>
);
}
Same problems we’ve always had:
- Specificity tax—inline wins every cascade war.
- No hover / focus styles without extra JavaScript.
- Dark mode? Good luck threading
styleobjects through every component.
1.2 Styled Components (the “JS in CSS” way)
const Button = styled.button`
@apply rounded px-3 py-2 text-sm text-gray-50;
background-color: ${props => props.color};
`;
Better co-location, but you’ve paid for:
- A runtime style tag generator in every bundle.
- Slower HMR in dev.
- A second mini-language (template-literal CSS) that ESLint and TypeScript barely understand.
1.3 Tailwind arbitrary values (the new way)
Tailwind’s JIT will happily digest any CSS value you throw in square brackets:
function Button({ color, children }) {
return (
<button className={`rounded px-3 py-2 text-sm text-gray-50 bg-[${color}]`}>
{children}
</button>
);
}
That literal bg-[${color}] becomes a static class at build time; the string never ships to the browser. Want to reuse the color? Hoist it into your tailwind.config.js:
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
brand: 'var(--color-brand)', // can be swapped by CSS custom prop
},
},
},
}
Now:
<button className="rounded px-3 py-2 text-sm text-gray-50 bg-brand">
...
</button>
No runtime cost, still totally dynamic (just update --color-brand anywhere in the cascade).
2 · States
A state is a set of styles that toggle together—variants like "primary" vs "secondary", or a "loading" flag.
2.1 Class-name concatenation (the clunky way)
function Button({ size, variant }) {
return (
<button
className={[
'rounded text-sm font-medium',
size === 'lg' ? 'px-5 py-3' : 'px-3 py-2',
variant === 'primary' ? 'bg-brand text-white' : 'border',
].join(' ')}
/>
);
}
Breakpoints, dark mode, nesting—this string explodes fast.
2.2 Styled Components variant helpers (still verbose)
const Button = styled.button(
({ size, variant }) => css`
@apply rounded text-sm font-medium;
${size === 'lg' ? 'padding: 12px 20px;' : 'padding: 8px 12px;'}
${variant === 'primary' ? 'background: var(--color-brand); color: #fff;' : 'border: 1px solid #ccc;'}
`
)
Readable for three variants; Herculean for ten.
2.3 Tailwind’s variant utilities (✨)
Tailwind already encodes common UI states as modifiers:
| Need | Tailwind class | Notes |
| ------------------ | ------------------------------------- | -------------------------------------------------------- |
| Hover & focus | hover:bg-brand focus:outline-none | Ships to CSS, no JS required |
| ARIA “pressed” | aria-pressed:bg-brand/20 | Requires tailwindcss-aria plugin (built-in since v3.4) |
| Loading “skeleton” | data-[loading=true]:animate-pulse | Variants for arbitrary data-* attributes |
| Dark mode | dark:bg-gray-800 | Uses class="dark" strategy by default |
Let’s revisit the same <Button> API, but delegate all styling logic to class names:
import { tv } from 'tailwind-variants'; // tiny 0-runtime helper
const button = tv({
base: 'inline-flex items-center justify-center rounded font-medium transition-colors',
variants: {
size: {
sm: 'px-3 py-2 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-3 text-base',
},
variant: {
primary: 'bg-brand text-white hover:bg-brand/90',
outline: 'border border-brand text-brand hover:bg-brand/5',
ghost: 'text-brand hover:bg-brand/5',
},
loading: {
true: 'relative text-transparent pointer-events-none',
},
},
compoundVariants: [
{
loading: true,
class: 'after:absolute after:inset-0 after:animate-pulse after:rounded inherit-bg',
},
],
defaultVariants: {
size: 'md',
variant: 'primary',
},
});
function Button({ size, variant, loading, children, ...rest }) {
return (
<button {...rest} className={button({ size, variant, loading })} disabled={loading}>
{children}
</button>
);
}
Key takeaways
- Zero runtime CSS—
tailwind-variantscomposes class strings, nothing more. - Predictable cascade—all styles live in
@applyrules or utilities compiled once at build. - Instant theming—swap
brandin the Tailwind config or set a CSS custom prop; the bundle stays the same size.
There’s a better way: Tailwind
CSS-in-JS proved we needed component-centred styling. Tailwind brings the same ergonomics, but:
- Compile-time, not runtime—no extra JavaScript in prod.
- Tooling-first—intellisense, lint rules, pre-built design tokens.
- First-class variants—dark, motion-safe, RTL, arbitrary
data-*and ARIA selectors. - Composable—slots right beside server components, Turbopack, RSC streaming—whatever the React future brings.
Want to see it in action? The entire /components folder in this repo is Tailwind-only—no .styled.tsx files in sight. ⚡️
Further reading
- Tailwind Arbitrary Values docs
- tailwind-variants (0 runtime variants helper)
- Radix UI's state-driven styles with Tailwind
Happy shipping!
