Styling

Choose a styling approach that does not drag your tree into the browser, and build a token layer that themes itself.

14of 16
≈ 10Min read
7Parts

The options, and what each costs

Server components?Notes
CSS ModulesYesBuilt in, scoped, zero runtime
TailwindYesBuilt in, no runtime, class-heavy markup
Plain CSS + variablesYesNothing to install; you own the naming
styled-components / EmotionNoNeeds "use client" wherever it is used
Vanilla Extract / PandaCSSYesTyped styles, compiled away at build
A runtime CSS-in-JS library pulls a component into the bundle

It generates styles while rendering, so the component needs the client runtime. In an App Router project that is a real cost: a styled header makes the whole header client code. Prefer something that compiles to a stylesheet.

CSS Modules

styles.card compiles to something like Card_card__3f9a2, so two files can both have a .card and never collide. It is plain CSS, it works in server components, and there is nothing to configure.

Card.module.css, and the component beside it
/* Card.module.css */
.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
.title { font-size: 17px; margin: 0 0 4px; }
.card:hover .title { color: var(--accent); }

// Card.jsx
import styles from "./Card.module.css";

export function Card({ door }) {
  return (
    <article className={styles.card}>
      <h3 className={styles.title}>{door.name}</h3>
    </article>
  );
}

Combining class names

the helper every project writes or installs
const cx = (...parts) => parts.filter(Boolean).join(" ");

<button className={cx(styles.btn, tone === "primary" && styles.primary, className)} />

// clsx does exactly this, plus objects:
clsx(styles.btn, { [styles.primary]: isPrimary })

Tokens, once, at the root

Every component then reads var(--fg). Switching the theme is one attribute on <html> — no re-render, no JavaScript in any component, and no flash if the attribute is set before the first paint.

globals.css — the only global stylesheet
:root {
  --bg: #ffffff;
  --fg: #14161a;
  --line: #e6e6e6;
  --accent: #0b6b5e;
  --radius: 10px;
}

[data-theme="dark"] {
  --bg: #0e0f12;
  --fg: #f2f2f2;
  --line: #24262b;
  --accent: #46d9c0;
}

body { background: var(--bg); color: var(--fg); }

Dark mode without a flash

The alternative — reading the preference in an effect — paints the light theme first and then corrects it. Everyone has seen that flash on somebody’s site.

the one inline script worth having
// in the root layout, before anything renders
<script dangerouslySetInnerHTML={{ __html: `
  try {
    const t = localStorage.theme ?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
    document.documentElement.dataset.theme = t;
  } catch {}
` }} />

Tailwind, briefly

It removes the naming problem and keeps the styles next to the markup, at the cost of markup that is harder to read. Both opinions about it are correct; pick one and be consistent.

the same card
<article className="rounded-[10px] border border-[--line] p-3.5 hover:border-[--accent]">
  <h3 className="mb-1 text-[17px]">{door.name}</h3>
</article>

// and the escape hatch, for anything repeated
// @apply in a component class, or a cva() variant function

Where the stylesheet goes

  • globals.css is imported once, in the root layout. Importing it twice duplicates it.
  • A module stylesheet is imported by the component that uses it, and only ships when that route does.
  • Next collects the CSS a route needs and inlines or links it — there is no flash of unstyled content and no configuration to write.
  • Order between modules is not guaranteed. Do not rely on one module overriding another; use a variant class or a variable.

Practice