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-10-03 · Against a production TanStack Start monorepo: Tailwind 4.3.3 (
@tailwindcss/vite), shadcn/ui primitives copied into one shared package,radix-ui1.6.7, CVA 0.7.1, clsx,tailwind-merge3.3.1, Phosphor 2.1.10. Excerpts are adapted, names changed.
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, CSS tokens, CVA example and executable boundary. This page remains the reference that owns the technology and styling rules.
- Every shadcn primitive is copied into one shared UI package (
@acme/ui), flat, one file per component, one export per file (@acme/ui/buttonissrc/button.tsx). Features never hold a copy of their own. - Tokens live in one theme package (
@acme/theme) that exports a singlestyles.css. Every app imports it; no app redefines a token. Its values are the design state. - Each app's stylesheet lists its Tailwind sources, and a test proves each one matches a file — see the trap below.
This replaces components.json and src/ui/components. With one shared
package there is no per-app CLI configuration to keep in step: a primitive is
copied once, into the package, and from then on it is edited like our own code.
When not to: a single app with no shared packages keeps the same flat folder
inside the app (src/ui/), and may keep components.json pointing at it.
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. - One icon library: Phosphor (
@phosphor-icons/react). The shared UI package and every product import it and nothing else; a second set brings a second visual language. See iconography. - 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. Screens that redraw a control or reach past the theme for a colour are refused by the design-system guard. - 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.
- A Tailwind
@sourcethat matches nothing → Tailwind drops it without a warning, the build stays green, and the stylesheet ships with none of those classes. The app's build directory holds only the shell, so every class the pages use comes from a declared source. A pattern ending at a directory wildcard (shared/web/*/src) matches nothing; glob files instead.
@import "@acme/theme/styles.css";
@source "../../product/src";
@source "../../../shared/web/*/src/**/*.{ts,tsx}";for (const stylesheet of files) {
describe(stylesheet, () => {
const declared = sources(readFileSync(join(ROOT, stylesheet), "utf8"));
it("declares its sources", () => {
expect(declared.length).toBeGreaterThan(0);
});
for (const pattern of declared) {
it(`${pattern} matches at least one file`, () => {
expect(matchesAFile(stylesheet, pattern)).toBe(true);
});
}
});
}Adapted from a production repository, where stale source paths once shipped every product unstyled. A sibling test asserts every deployable app has a stylesheet in the list, so an app cannot drop out of the check silently.
- 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.