know.2nth.ai Design frameworks
design · Frameworks · Skill Leaf

How you write CSS
is a team decision.

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.

Live Tailwind · shadcn/ui Radix UI CSS Modules

Three strategies for styling, one codebase to maintain.

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.

The code behind each approach.

/* 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>
  )
}
ApproachRuntime costDXBest for
TailwindZero — static CSSFast iteration, verbose markupTeams that ship fast and value small bundles
CSS ModulesZero — build-time hashingFamiliar CSS, scoped by defaultTeams migrating from vanilla CSS
CSS-in-JSRuntime JS overheadDynamic styles, theme access in JSDesign-heavy apps needing runtime theming
shadcn/uiZero — Tailwind underneathCopy-paste, full ownershipReact + Tailwind projects wanting pre-built components
Radix UIMinimal — behaviour onlyAccessible primitives, BYOSTeams wanting accessibility without style opinions

The mistakes everyone makes once.

Mixing strategies without boundaries

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).

Tailwind's content config is load-bearing

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.

shadcn/ui is not a package you update

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.

CSS-in-JS has real runtime cost on SSR

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.

Pick the framework that fits the team.

Go Tailwind when

  • The team values speed over CSS purity and can read utility classes fluently.
  • You want tiny production bundles without a CSS-in-JS runtime.
  • You're pairing with shadcn/ui or building a design system with Tailwind's config as the token layer.
  • AI agents are generating your markup — LLMs produce surprisingly good Tailwind.

Where frameworks link in the tree.

Go deeper.