Tailwind CSS Best Practices for Large Projects in 2026
- Tailwind v4 is CSS-first β @theme defines tokens, @source defines scan paths, @utility defines custom utilities
- Component extraction preserves tree-shaking β styles are visible, co-located, and importable
- prettier-plugin-tailwindcss auto-sorts classes β enforce in CI to prevent conflicts
- Tailwind v4 uses CSS-first configuration β all config lives in your CSS file (@theme replaces tailwind.config.js)
- The @theme directive defines design tokens as CSS custom properties β accessible to both Tailwind and vanilla CSS
- Class sorting and grouping prevent style chaos β use prettier-plugin-tailwindcss in CI
- Extract components early β repeated class patterns become reusable components, not @apply utilities
- shadcn/ui provides the component layer β do not restyle its primitives, override via CSS variables
- Biggest mistake: using @apply everywhere β it defeats Tailwind's utility-first approach and bloats CSS output
Unused classes in production bundle
npx tailwindcss --content './app/**/*.{ts,tsx}' --output /tmp/tw-debug.css 2>&1 | head -5wc -c /tmp/tw-debug.css && grep -c '@apply' app/ components/ lib/ -rn 2>/dev/null || echo 'No @apply found'Class not found in production
cat tailwind.config.ts 2>/dev/null || cat app/globals.css | head -30grep -rn 'missing-class-name' app/ components/ --include='*.tsx' | head -5Dark mode not working
grep -rn 'dark:' app/ components/ --include='*.tsx' | head -10cat app/globals.css | grep -A 5 'dark\|@custom-variant'shadcn/ui theme colors not updating
cat app/globals.css | grep -A 30 ':root'cat app/globals.css | grep -A 30 '.dark'Production Incident
Production Debug GuideDiagnose styling conflicts, bundle size, and configuration issues
Tailwind CSS scales well when the team follows consistent patterns. The problems in large projects are not technical limitations β they are organizational: inconsistent class ordering, duplicated utility patterns, opaque design tokens, and components that override each other's styles in unpredictable ways.
Tailwind v4 (alpha as of early 2026) changes the configuration model. The tailwind.config.js file is replaced by CSS-first configuration using @theme, @custom-variant, and @utility directives. This moves design tokens into CSS custom properties, making them accessible to both Tailwind utilities and vanilla CSS. Combined with shadcn/ui's CSS variable-based theming, this creates a design system that is both flexible and constrained.
Note: Tailwind v4 features shown here are based on the current alpha release. The API may change before stable release. For production today, use Tailwind v3 with the patterns shown (component extraction, cn(), etc.).
This guide covers the patterns that work at scale: CSS-first configuration, component extraction strategies, class organization, performance optimization, and the shadcn/ui integration that prevents the most common styling conflicts.
Tailwind v4: CSS-First Configuration
Tailwind v4 eliminates tailwind.config.js. All configuration moves into CSS using directives: @theme for design tokens, @custom-variant for custom variants, @utility for reusable utility patterns, and @source for content paths. This is a fundamental shift β design tokens become CSS custom properties that are accessible to both Tailwind utilities and vanilla CSS.
The @theme directive defines your design system: colors, spacing, typography, breakpoints, and animations. Each @theme value becomes a CSS custom property (--color-primary, --spacing-md) and a corresponding Tailwind utility (bg-primary, p-md). This eliminates the gap between Tailwind's theme config and CSS custom properties β they are the same thing.
The migration from v3 to v4 is straightforward for most projects: move colors and spacing from tailwind.config.js to @theme, replace the content array with @source globs, and convert any plugins to @utility or @custom-variant directives.
/* ============================================ Tailwind v4 β CSS-First Configuration ============================================ */ @import "tailwindcss"; /* ---- Content paths (replaces content array in config) ---- */ /* Tailwind scans these paths for class usage */ @source "../app/**/*.{ts,tsx}"; @source "../components/**/*.{ts,tsx}"; @source "../lib/**/*.{ts,tsx}"; /* ---- Design tokens via @theme ---- */ /* Each value becomes a CSS custom property AND a Tailwind utility */ /* --color-primary -> bg-primary, text-primary, border-primary */ /* --spacing-md -> p-md, m-md, gap-md, etc. */ @theme { /* Colors */ --color-background: hsl(0 0% 100%); --color-foreground: hsl(240 10% 3.9%); --color-primary: hsl(240 5.9% 10%); --color-primary-foreground: hsl(0 0% 98%); --color-secondary: hsl(240 4.8% 95.9%); --color-secondary-foreground: hsl(240 5.9% 10%); --color-muted: hsl(240 4.8% 95.9%); --color-muted-foreground: hsl(240 3.8% 46.1%); --color-accent: hsl(240 4.8% 95.9%); --color-accent-foreground: hsl(240 5.9% 10%); --color-destructive: hsl(0 84.2% 60.2%); --color-destructive-foreground: hsl(0 0% 98%); --color-border: hsl(240 5.9% 90%); --color-input: hsl(240 5.9% 90%); --color-ring: hsl(240 5.9% 10%); /* Spacing scale */ --spacing-xs: 0.25rem; --spacing-sm: 0.5rem; --spacing-md: 1rem; --spacing-lg: 1.5rem; --spacing-xl: 2rem; --spacing-2xl: 3rem; /* Border radius */ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 0.75rem; /* Typography */ --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, monospace; /* Animations */ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; --animate-fade-in: fade-in 0.3s ease-out; --animate-slide-up: slide-up 0.3s ease-out; } /* ---- Keyframes for custom animations ---- */ @keyframes accordion-down { from { height: 0; } to { height: var(--radix-accordion-content-height); } } @keyframes accordion-up { from { height: var(--radix-accordion-content-height); } to { height: 0; } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes slide-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } /* ---- Dark mode variant ---- */ @custom-variant dark (&:is(.dark *)); /* ---- Dark theme overrides ---- */ .dark { --color-background: hsl(240 10% 3.9%); --color-foreground: hsl(0 0% 98%); --color-primary: hsl(0 0% 98%); --color-primary-foreground: hsl(240 5.9% 10%); --color-secondary: hsl(240 3.7% 15.9%); --color-secondary-foreground: hsl(0 0% 98%); --color-muted: hsl(240 3.7% 15.9%); --color-muted-foreground: hsl(240 5% 64.9%); --color-accent: hsl(240 3.7% 15.9%); --color-accent-foreground: hsl(0 0% 98%); --color-destructive: hsl(0 62.8% 30.6%); --color-destructive-foreground: hsl(0 0% 98%); --color-border: hsl(240 3.7% 15.9%); --color-input: hsl(240 3.7% 15.9%); --color-ring: hsl(240 4.9% 83.9%); } /* ---- Custom utility (replaces plugins for simple cases) ---- */ @utility text-balance { text-wrap: balance; } @utility scrollbar-thin { scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; } /* ---- Base layer customizations ---- */ @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; font-feature-settings: "rlig" 1, "calt" 1; } }
- @theme values become CSS custom properties (--color-primary) and Tailwind utilities (bg-primary)
- No gap between Tailwind config and CSS β they are the same definitions
- Dark mode overrides the same custom properties β no separate Tailwind config needed
- @source replaces the content array β glob patterns specify which files to scan
- @utility replaces simple plugins β defines custom utilities that Tailwind can tree-shake
Component Extraction: The Right Way to Reuse Styles
The correct way to reuse Tailwind patterns is component extraction β not @apply. When a combination of utilities appears in 3 or more places, extract it as a React component with the class string directly on the element. This preserves Tailwind's tree-shaking, keeps styles co-located with their markup, and avoids the cognitive overhead of custom CSS classes.
@Apply creates an opaque abstraction layer. Developers must learn which custom classes exist, what utilities they contain, and where they are defined. Tailwind's content scanner cannot trace @Apply usage reliably, which leads to bloated CSS bundles. Component extraction avoids all of these problems β the styles are visible on the element, the component is importable, and Tailwind can tree-shake correctly.
// ============================================ // Component Extraction β The Right Way to Reuse Styles // ============================================ // ---- WRONG: @apply creates opaque abstractions ---- // This is a CSS class that hides the actual utilities // Tailwind cannot tree-shake this reliably /* styles.css */ /* .btn-primary { @apply inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; } */ // ---- CORRECT: Extract as a React component ---- // Styles are visible, tree-shakeable, and co-located // File: components/ui/button.tsx import * as React from 'react' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( // Base classes β applied to all variants 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button' return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } ) Button.displayName = 'Button' export { Button, buttonVariants } // ---- Usage ---- // Clear, explicit, no hidden abstractions import { Button } from '@/components/ui/button' export function ActionBar() { return ( <div className="flex gap-2"> <Button variant="default" size="sm">Save</Button> <Button variant="outline" size="sm">Cancel</Button> <Button variant="destructive" size="sm">Delete</Button> </div> ) } // ---- WRONG: @apply for layout patterns ---- /* .page-container { @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; } */ // ---- CORRECT: Extract as a layout component ---- // File: components/ui/container.tsx export function Container({ children, className, }: { children: React.ReactNode className?: string }) { return ( <div className={cn('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)}> {children} </div> ) } // ---- WRONG: @apply for card patterns ---- /* .card { @apply rounded-lg border bg-card text-card-foreground shadow-sm; } */ // Extract as CardHeader, CardContent, etc. export function CardHeader({ children, className, }: { children: React.ReactNode className?: string }) { return ( <div className={cn('flex flex-col space-y-1.5 p-6', className)}> {children} </div> ) } export function CardContent({ children, className, }: { children: React.ReactNode className?: string }) { return ( <div className={cn('p-6 pt-0', className)}> {children} </div> ) } // ---- The cn() utility: merge class names safely ---- // File: lib/utils.ts import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } // cn() resolves conflicts: // cn('px-4 py-2', 'px-6') => 'py-2 px-6' (px-6 overrides px-4) // clsx alone would produce 'px-4 py-2 px-6' β both applied, last wins by CSS order // twMerge understands Tailwind β resolves the conflict correctly
Class Organization: Sorting, Grouping, and Readability
In large projects, class lists grow long and inconsistent. Without a sorting convention, developers order classes differently β some group by function (layout, typography, color), others alphabetically, others arbitrarily. This creates cognitive overhead during code review and can cause CSS specificity conflicts when the same utility appears in different positions across components.
prettier-plugin-tailwindcss solves the ordering problem by auto-sorting classes in a deterministic order. Install it as a dev dependency, add it to your Prettier config, and enforce it in CI with prettier --check. The plugin sorts classes by category: layout, spacing, sizing, typography, visual effects, then interactive states.
For long class lists (20+ classes), group them with comments to improve readability. Each group represents a styling concern: layout, spacing, typography, visual, responsive overrides, dark mode, and conditional states. The cn() utility from tailwind-merge resolves class conflicts and filters falsy values, making conditional styling safe and predictable.
// ============================================ // Class Organization β Sorting, Grouping, Readability // ============================================ // ---- Grouped class list with comments ---- // Each comment marks a styling concern import { cn } from '@/lib/utils' export function Card({ children, isActive, className, }: { children: React.ReactNode isActive: boolean className?: string }) { return ( <div className={cn( // Layout 'flex flex-col', // Spacing 'gap-4 p-6', // Sizing 'w-full max-w-md', // Typography 'text-sm leading-relaxed', // Visual 'rounded-lg border bg-card shadow-sm', // Interactive 'transition-all hover:shadow-md', // Responsive 'sm:flex-row sm:items-center', // Dark mode 'dark:bg-card dark:shadow-none', // Conditional isActive && 'border-primary ring-2 ring-primary/20', // Custom overrides className )} > {children} </div> ) } // ---- Tailwind v4: @utility for genuinely reusable patterns ---- // Only for patterns that cannot be components (global styles, base layer) /* globals.css */ /* @utility prose { color: var(--color-foreground); max-width: 65ch; line-height: 1.75; } @utility prose h1, @utility prose h2, @utility prose h3 { color: var(--color-foreground); font-weight: 700; margin-top: 2em; margin-bottom: 0.5em; } @utility prose p { margin-bottom: 1em; } @utility prose a { color: var(--color-primary); text-decoration: underline; } */ // ---- File organization: Where to put components ---- // Flat structure for small projects, domain-based for large projects /* Small project (< 50 components): components/ ui/ <- Primitive components (button, input, card) layout/ <- Layout components (header, sidebar, footer) features/ <- Feature-specific components Large project (50+ components): components/ ui/ <- Primitive components (shared across features) button.tsx input.tsx card.tsx dialog.tsx layout/ <- Layout primitives container.tsx grid.tsx stack.tsx dashboard/ <- Dashboard domain stats-card.tsx activity-feed.tsx chart-widget.tsx billing/ <- Billing domain invoice-form.tsx payment-method.tsx subscription-card.tsx settings/ <- Settings domain profile-form.tsx notification-prefs.tsx danger-zone.tsx */ // ---- Tailwind IntelliSense setup ---- // File: .vscode/settings.json // { // "tailwindCSS.experimental.classRegex": [ // ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], // ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], // ["clsx\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], // ["twMerge\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], // ], // "tailwindCSS.classAttributes": ["class", "className", "ngClass"], // "editor.quickSuggestions": { // "strings": "on" // } // }
- prettier-plugin-tailwind --check
- Sort order: layout > spacing > sizing > typography > visual > interactive
- Long class lists: break into groups with comments β layout, spacing, visual, responsive, dark mode
- Use cn() for conditional classes β it resolves conflicts and handles falsy values
- Tailwind IntelliSense needs classRegex config for cn(), cva(), clsx() β add to .vscode/settings.json
shadcn/ui Integration: Theming Without Conflicts
shadcn/ui provides copy-paste component primitives that use Tailwind utilities and CSS variables for theming. The integration point is CSS variables β shadcn/ui components read from --background, --primary, --muted, etc. defined in your globals.css. To customize the theme, update the CSS variables β do not override the component's Tailwind classes.
The key pattern: shadcn/ui components are designed to be owned by your codebase. They are not installed as a dependency β they are copied into your project. This means you can modify them, but the modification should happen at the component level (changing the component's markup), not by overriding their styles from the outside with !important or higher-specificity selectors.
// ============================================ // shadcn/ui Integration β Theming and Customization // ============================================ // ---- How shadcn/ui reads theme values ---- // Components use CSS variables via Tailwind's arbitrary value syntax // shadcn/ui button (simplified): // bg-primary -> background-color: var(--color-primary) // text-primary-foreground -> color: var(--color-primary-foreground) // border-input -> border-color: var(--color-input) // To change the button color, update --color-primary in globals.css // Do NOT override with: .btn { background: red !important } // ---- WRONG: Overriding shadcn/ui styles from outside ---- // This creates specificity wars and maintenance debt /* .my-button-override { background: #3b82f6 !important; color: white !important; } */ // ---- CORRECT: Update CSS variables in globals.css ---- // This changes ALL components that use --color-primary /* globals.css */ /* @theme { --color-primary: hsl(221.2 83.2% 53.3%); // Blue instead of dark --color-primary-foreground: hsl(0 0% 100%); } */ // ---- CORRECT: Extend the component for custom needs ---- // Modify the component itself, not override from outside // File: components/ui/button.tsx (modified) import { cva, type VariantProps } from 'class-variance-authority' const buttonVariants = cva( 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors', { variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', // Add custom variants here β they use your theme variables brand: 'bg-brand text-brand-foreground hover:bg-brand/90', success: 'bg-emerald-600 text-white hover:bg-emerald-700', }, size: { default: 'h-10 px-4 py-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', // Add custom sizes xl: 'h-12 rounded-lg px-10 text-base', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ) // ---- shadcn/ui CLI: Adding components ---- // npx shadcn@latest add button // npx shadcn@latest add dialog // npx shadcn@latest add form // npx shadcn@latest add data-table // ---- shadcn/ui components.json ---- // File: components.json // { // "$schema": "https://ui.shadcn.com/schema.json", // "style": "new-york", // "rsc": true, // "tsx": true, // "tailwind": { // "config": "", // "css": "app/globals.css", // "baseColor": "neutral", // "cssVariables": true // }, // "aliases": { // "components": "@/components", // "utils": "@/lib/utils", // "ui": "@/components/ui", // "lib": "@/lib", // "hooks": "@/hooks" // } // } // ---- Composing shadcn/ui components ---- // Combine primitives to build feature components // File: components/dashboard/stats-card.tsx import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { cn } from '@/lib/utils' interface StatsCardProps { title: string value: string | number change?: number className?: string } export function StatsCard({ title, value, change, className }: StatsCardProps) { return ( <Card className={cn('', className)}> <CardHeader className="pb-2"> <CardTitle className="text-sm font-medium text-muted-foreground"> {title} </CardTitle> </CardHeader> <CardContent> <div className="text-2xl font-bold">{value}</div> {change !== undefined && ( <p className={cn( 'text-xs', change >= 0 ? 'text-emerald-600' : 'text-red-600' )} > {change >= 0 ? '+' : ''}{change}% from last month </p> )} </CardContent> </Card> ) } // ---- Usage: shadcn/ui + custom components together ---- import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { StatsCard } from '@/components/dashboard/stats-card' export function Dashboard() { return ( <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <StatsCard title="Total Revenue" value="$45,231.89" change={20.1} /> <StatsCard title="Subscriptions" value="+2,350" change={180.1} /> <StatsCard title="Active Users" value="+12,234" change={19} /> <StatsCard title="Churn Rate" value="-2.4%" change={-4.5} /> <Dialog> <DialogTrigger asChild> <Button variant="outline">Create Invoice</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>New Invoice</DialogTitle> </DialogHeader> {/* Form content */} </DialogContent> </Dialog> </div> ) }
- shadcn/ui components are owned by your codebase β modify via CSS variables and cva, not external overrides
- Update CSS variables in globals.css to change the theme β one file controls all components
- Add custom variants and sizes to the cva definition β do not override from the outside
- Compose shadcn/ui primitives into feature components β Card + Stats = StatsCard
- shadcn/ui CLI (npx shadcn@latest add) adds components with your theme variables pre-configured
Performance: CSS Bundle Size and Build Optimization
Tailwind generates a large utility set by default. In production, the content scanner removes unused classes β but only if the scanner paths are configured correctly. Misconfigured paths, @apply abuse, and dynamic class construction prevent tree-shaking and bloat the bundle.
The three rules for CSS bundle performance: configure content paths precisely, never construct class names dynamically (Tailwind cannot detect them), and avoid @apply (it prevents tree-shaking). Monitor bundle size in CI β a sudden increase indicates a configuration regression.
// ============================================ // Tailwind Performance β Bundle Size Optimization // ============================================ // ---- Rule 1: Content paths must be precise ---- // Include only files that use Tailwind classes /* globals.css */ /* @source "../app/**/*.{ts,tsx}"; @source "../components/**/*.{ts,tsx}"; @source "../lib/**/*.{ts,tsx}"; // Do NOT include: // @source "../**/*.{ts,tsx}"; <- Too broad, scans node_modules // @source "../.next/**/*"; <- Build artifacts, includes generated code */ // ---- Rule 2: Never construct class names dynamically ---- // Tailwind scans for literal class strings β it cannot evaluate expressions // WRONG: Dynamic class construction β Tailwind cannot detect these function getBadgeColor(status: string) { const colors: Record<string, string> = { active: 'bg-green-500 text-white', inactive: 'bg-gray-500 text-white', pending: 'bg-yellow-500 text-black', } return colors[status] ?? 'bg-gray-500 text-white' } // This works at runtime, but Tailwind cannot detect the classes // during build. They may be purged if not used elsewhere. // CORRECT: Use a lookup object with full class strings // AND list all possible classes in a safelist comment /* safelist comment β ensures Tailwind includes these classes */ /* tailwind safelist: bg-green-500 text-white bg-gray-500 text-white bg-yellow-500 text-black */ // OR: Use cva for variants (preferred) import { cva } from 'class-variance-authority' const badgeVariants = cva( 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', { variants: { status: { active: 'bg-green-500 text-white', inactive: 'bg-gray-500 text-white', pending: 'bg-yellow-500 text-black', }, }, defaultVariants: { status: 'active', }, } ) // cva outputs full class strings β Tailwind detects them during build // ---- Rule 3: Avoid template literals for class names ---- // WRONG: Template literal β Tailwind cannot parse this const size = 'md' const className = `p-${size === 'sm' ? '2' : size === 'md' ? '4' : '6'}` // CORRECT: Map sizes to full class strings const sizeClasses = { sm: 'p-2', md: 'p-4', lg: 'p-6', } as const const className2 = sizeClasses[size as keyof typeof sizeClasses] // ---- Bundle size monitoring in CI ---- // File: scripts/check-css-size.sh // #!/bin/bash // # Build CSS and check size // npx tailwindcss --input app/globals.css --output /tmp/tw-output.css --minify // SIZE=$(wc -c < /tmp/tw-output.css) // MAX_SIZE=150000 # 150KB max // // if [ $SIZE -gt $MAX_SIZE ]; then // echo "CSS bundle too large: ${SIZE} bytes (max: ${MAX_SIZE})" // echo "Check for @apply usage or dynamic class construction" // exit 1 // fi // // echo "CSS bundle OK: ${SIZE} bytes" // ---- Performance comparison ---- /* Scenario | CSS Size (minified) | Gzipped ---------------------------|--------------------|-------- Clean Tailwind (10 pages) | 12KB | 3KB With @apply (180 classes) | 450KB | 92KB Component extraction | 142KB | 22KB After optimization | 45KB | 8KB */ // ---- Tailwind v4: Built-in performance features ---- // v4 uses Lightning CSS for faster builds and smaller output /* Key v4 performance improvements: - Lightning CSS replaces PostCSS β 10x faster builds - Automatic content detection in v4 β no content array needed for most projects - Smaller default utility set β only generates utilities used in your project - @utility directives are tree-shakeable β unlike @apply in v3 */
| Feature | Tailwind v3 | Tailwind v4 | Migration Action |
|---|---|---|---|
| Configuration | tailwind.config.js | CSS-first (@theme, @source) | Move config to globals.css directives |
| Design Tokens | theme.extend in JS | @theme CSS custom properties | Move colors, spacing, fonts to @theme |
| Custom Variants | Plugins | @custom-variant directive | Convert plugins to @custom-variant |
| Custom Utilities | Plugins with addUtilities | @utility directive | Convert simple plugins to @utility |
| Content Scanning | content array in config | @source directive or auto-detection | Replace content array with @source globs |
| Build Engine | PostCSS | Lightning CSS | Faster builds, smaller output |
| Container Queries | Plugin required | Built-in (@container) | Remove plugin, use @container directly |
| CSS Bundle Size | Larger default set | Smaller β only used utilities | No action β automatic improvement |
| @apply | Supported | Supported but @utility preferred | Convert @apply to @utility or components |
π― Key Takeaways
- Tailwind v4 is CSS-first β @theme defines tokens, @source defines scan paths, @utility defines custom utilities
- Component extraction preserves tree-shaking β styles are visible, co-located, and importable
- prettier-plugin-tailwindcss auto-sorts classes β enforce in CI to prevent conflicts
- shadcn/ui theming is CSS-variable driven β update variables in globals.css, do not override components
- Dynamic class construction breaks tree-shaking β use cva or lookup objects with full strings
- Monitor CSS bundle size in CI β a sudden increase indicates @apply abuse or misconfigured content paths
β Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between @apply and component extraction in Tailwind, and when should you use each?Mid-levelReveal
- QHow does Tailwind v4's CSS-first configuration differ from v3's JavaScript config?Mid-levelReveal
- QHow do you integrate shadcn/ui with Tailwind without creating style conflicts?Mid-levelReveal
- QWhy does dynamic class construction break Tailwind's tree-shaking, and how do you work around it?SeniorReveal
- QWhat is the cn() utility and why is it necessary for Tailwind projects?JuniorReveal
Frequently Asked Questions
Should I use Tailwind CSS or CSS Modules for a large project?
Tailwind CSS is better for large projects because it enforces consistency through a constrained utility set. CSS Modules allow any CSS, which leads to divergent styling patterns across teams. Tailwind's utility-first approach, combined with prettier-plugin-tailwindcss for sorting and cva for variants, creates a more maintainable codebase at scale. CSS Modules are better for isolated, highly custom components where Tailwind's utility vocabulary is insufficient.
How do I handle styles that Tailwind utilities cannot express?
Use @utility in Tailwind v4 for complex CSS that cannot be expressed as utilities (prose styles, complex animations, custom scrollbars). For component-specific styles, use inline style attributes or a scoped CSS file. Avoid creating a global CSS file with arbitrary selectors β this defeats Tailwind's scoping model.
Can I use Tailwind with a design system that has strict spacing and color scales?
Yes. Define your design system's tokens in @theme β replace Tailwind's default spacing and color scales with your own. This ensures all components use the approved tokens. Developers cannot use arbitrary values (p-[13px]) unless explicitly enabled. The constrained utility set enforces design system compliance.
How do I migrate from Tailwind v3 to v4?
Three steps: 1) Move colors, spacing, and fonts from tailwind.config.js to @theme in globals.css. 2) Replace the content array with @source globs. 3) Convert plugins to @utility or @custom-variant directives. Run the Tailwind v4 codemod (npx @tailwindcss/upgrade) for automated migration. Test thoroughly β some utility names and defaults changed.
How do I test Tailwind-styled components?
Tailwind-styled components can be tested with any testing framework (Vitest, Jest, React Testing Library) because Tailwind compiles to regular CSS classes. Three approaches:
- Snapshot tests β capture the rendered HTML with class names. Fragile because any class change breaks the snapshot.
- Visual regression tests β capture screenshots and compare pixel-by-pixel. Tools like Playwright, Percy, or Chromatic. Best for catching unintended visual changes.
- Class assertion tests β assert that specific Tailwind classes are present on elements. Use getByRole or getByText to find elements, then check className for expected classes.
For unit testing, ensure Tailwind is compiled before tests run. In Vitest, use the @tailwindcss/vite plugin or configure PostCSS in the test environment.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.