Tailwind, shadcn/ui, Radix, CSS Modules, CSS-in-JS — these are not interchangeable. Each makes a trade between authoring speed, runtime cost, and team ergonomics. The right choice depends on your project shape, not on which framework has the most GitHub stars this week.
Utility-first (Tailwind CSS) puts classes directly in markup. No separate stylesheet, no naming debate, no specificity wars. The JIT compiler scans your templates and generates only the CSS you actually use. The result is tiny bundles and fast iteration — at the cost of verbose HTML that some teams find unreadable.
Component-scoped (CSS Modules / CSS-in-JS) keeps styles co-located with the component that uses them. CSS Modules do it at build time with class name hashing — zero runtime cost. CSS-in-JS (styled-components, Emotion) does it at runtime with JS-generated stylesheets — more flexible, more overhead.
Unstyled primitives (Radix UI, Headless UI) give you accessible behaviour with zero opinions on appearance. You bring the styling — Tailwind, CSS Modules, vanilla CSS, whatever. shadcn/ui takes this further: it generates Radix-based components into your codebase, pre-styled with Tailwind, so you own the code outright.
/* tailwind.config.ts — extending the default theme */ import type { Config } from 'tailwindcss' export default { content: ['./src/**/*.{ts,tsx}'], theme: { extend: { colors: { ink: '#0B1120', navy: '#121D33', sky: '#38BDF8', }, fontFamily: { sans: ['Outfit', 'system-ui', 'sans-serif'], mono: ['JetBrains Mono', 'monospace'], }, borderRadius: { lg: '20px', md: '12px', }, }, }, plugins: [], } satisfies Config
{/* shadcn/ui — a component you own, not a dependency */} import { Button } from "@/components/ui/button" {/* This file lives in YOUR repo — edit it freely */} export function SubmitAction() { return ( <Button variant="default" size="lg"> Deploy to production </Button> ) } {/* Install a new component: npx shadcn@latest add dialog */} {/* It copies the source into components/ui/dialog.tsx */}
{/* Radix primitive — accessible, unstyled, composable */} import * as Dialog from '@radix-ui/react-dialog' export function ConfirmDialog({ children }) { return ( <Dialog.Root> <Dialog.Trigger asChild>{children}</Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 bg-black/50" /> <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-navy rounded-lg p-6"> <Dialog.Title>Confirm</Dialog.Title> <Dialog.Description>This action cannot be undone.</Dialog.Description> <Dialog.Close asChild> <button className="mt-4 px-4 py-2 bg-blue rounded-md text-paper"> Confirm </button> </Dialog.Close> </Dialog.Content> </Dialog.Portal> </Dialog.Root> ) }
| Approach | Runtime cost | DX | Best for |
|---|---|---|---|
Tailwind | Zero — static CSS | Fast iteration, verbose markup | Teams that ship fast and value small bundles |
CSS Modules | Zero — build-time hashing | Familiar CSS, scoped by default | Teams migrating from vanilla CSS |
CSS-in-JS | Runtime JS overhead | Dynamic styles, theme access in JS | Design-heavy apps needing runtime theming |
shadcn/ui | Zero — Tailwind underneath | Copy-paste, full ownership | React + Tailwind projects wanting pre-built components |
Radix UI | Minimal — behaviour only | Accessible primitives, BYOS | Teams wanting accessibility without style opinions |
Tailwind in one component, styled-components in another, a global stylesheet overriding both. Pick one primary approach and stick to it. A second approach is allowed only if it's scoped to a clear boundary (e.g., a third-party widget).
If your content array doesn't include a file path, Tailwind's JIT won't scan it, and those classes won't exist in the output. Every mysterious "class not working" bug starts here.
It copies files into your project. Once you edit them, there's no npm update path. That's the design — you own the code — but it means you also own the maintenance. Don't treat it like a dependency.
Libraries like styled-components inject styles via JavaScript. On the server, that means serialising stylesheets into the HTML response. With React Server Components, several CSS-in-JS libraries simply don't work. Check compatibility before committing.