Skip to main content

You (probably) don't need CSS-in-JS Tailwind Edition

00:03:58:93

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)

jsx
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 style objects through every component.

1.2 Styled Components (the “JS in CSS” way)

jsx
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:

jsx
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:

js
// tailwind.config.js
export default {
  theme: {
    extend: {
      colors: {
        brand: 'var(--color-brand)',   // can be swapped by CSS custom prop
      },
    },
  },
}

Now:

jsx
<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)

jsx
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)

jsx
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:

jsx
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 CSStailwind-variants composes class strings, nothing more.
  • Predictable cascade—all styles live in @apply rules or utilities compiled once at build.
  • Instant theming—swap brand in 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

Happy shipping!