The options, and what each costs
| Server components? | Notes | |
|---|---|---|
| CSS Modules | Yes | Built in, scoped, zero runtime |
| Tailwind | Yes | Built in, no runtime, class-heavy markup |
| Plain CSS + variables | Yes | Nothing to install; you own the naming |
| styled-components / Emotion | No | Needs "use client" wherever it is used |
| Vanilla Extract / PandaCSS | Yes | Typed styles, compiled away at build |
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 */
.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
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.
: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.
// 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.
<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.cssis 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.