Tailwind and shadcn/ui
Always, not a per-project debate — shadcn is copied source that gets versioned, reviewed and edited like our own code.
Verified 2026-07-31 · Against Tailwind v4 (
@tailwindcss/vite), shadcn/ui (new-york), CVA, clsx, lucide/phosphor icons.
Tailwind + shadcn/ui, always. Not a per-project debate.
shadcn is copied source, not a dependency — it gets versioned, reviewed and edited like our own code, which is precisely why it beats a component library we would have to fight or fork.
No CSS-in-JS, no CSS Modules, no SCSS, no competing component library.
Setup that works
Follow Set up the UI system for the repository
shape, components.json, CSS tokens, CVA example and executable boundary. This
page remains the reference that owns the technology and styling rules.
Rules
- Style with utilities; extract to a component, not to a class. If a utility
string repeats, the answer is a component or a CVA variant — never
@applyinto a bespoke class. - Design tokens over raw values. Spacing, radius and colour come from the
theme; an arbitrary value (
[13px]) in a review is a question, not a default. - Gate it in CI. A script that fails the build on any
*.module.cssfile or reference costs 20 lines and permanently ends the "just this once" exception. Do the same for any banned styling import. - Keep the design system dependency-free of features. Lower layers never import up.
Traps
- shadcn components edited in place, then re-added by the CLI → the CLI overwrites. Treat added components as ours from the moment they land, and note any local modification in the file.
- Tailwind v3 config habits on v4 → the JS config is gone. Check the current docs before copying a snippet from memory or from an older project.
- Class strings assembled by concatenation → Tailwind's scanner cannot see
them and the classes get purged. Always full literal class names, with
clsxfor conditionals. - A controlled
Selecthandedundefined→ Radix treats it as uncontrolled, and the trigger can then display a choice React state does not have. Always pass a string;""for unset, which still renders the placeholder.
Traps under server rendering
Two hydration failures live in the theming layer. Both look like a browser extension until you read the diff.
The theme script beats the server. Theming libraries inject an inline script
that stamps the document element during parsing, before hydration, so there is no
flash. With attribute="class" it writes class="light" style="color-scheme: light"
onto an <html> the server rendered bare — and React reports a mismatch on every
route. The documented answer is suppressHydrationWarning, which silences the
report without fixing the divergence. When the theme is forced, both values are
constants: render them.
<html className="light" lang="…" style={{ colorScheme: "light" }}>The toast surface sits outside the provider. Notification surfaces are usually
mounted at the root beside the provider rather than inside it. Their useTheme()
then resolves against the default context and falls back to system, so toasts
follow the operating system while the product is pinned to one theme. Move it
inside, and read the forced value first — theme is the chosen theme, which the
library keeps even under forcedTheme:
const { forcedTheme, theme = "system" } = useTheme()
<Toaster theme={forcedTheme ?? theme} />Text nodes in <title>. Same family, and it costs an afternoon. Elements whose
content the DOM normalises — <title>, <textarea>, <option> — must receive a
single interpolated string, never several children.
<title>{`Monthly filings, ${current} against ${previous}`}</title>The general rule: a value that both the server and a pre-hydration script write
must be rendered by the server. Verify by reading the SSR output, not by watching
the screen — curl -s localhost:3000 | head -c 200.
What you build with it
shadcn gives you primitives, not a design system. What makes it one is the decisions layered on top — which variants exist, what a status looks like, how a destructive action announces itself. See the design system.
The craft rules are a separate concern and they are not optional: layout and spacing for rhythm and hierarchy, motion and polish for component shape and animation.