Skip to content

Tailwind CSS + shadcn/ui — Pitfalls

OWNER: alexander ALSO_USED_BY: floris, floor LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: tailwindcss ^4, shadcn ^3.8.4


Overview

Known failure modes when using Tailwind v4 and shadcn/ui in GE projects. Every pitfall here was either encountered in production or is a documented v4 migration trap. Agents MUST read this page before starting UI work.


Pitfall 1: Dynamic Class Names Are Purged

Tailwind scans source files as plain text — it does NOT execute JavaScript. Dynamically constructed class names are invisible to the scanner.

ANTI_PATTERN: String interpolation for Tailwind classes.

// BROKEN — Tailwind cannot detect these classes
const color = "blue"
className={`bg-${color}-500 text-${color}-100`}

// BROKEN — variable class names
const size = isLarge ? "lg" : "sm"
className={`text-${size}`}

FIX: Use complete class names that Tailwind can scan.

// CORRECT — complete strings, scannable
const colorClasses = {
  blue: "bg-blue-500 text-blue-100",
  red: "bg-red-500 text-red-100",
}
className={colorClasses[color]}

// CORRECT — ternary with full class names
className={isLarge ? "text-lg" : "text-sm"}

FIX: For truly dynamic values, use inline styles or CSS variables.

// CORRECT — dynamic values via CSS variable
style={{ "--progress": `${percent}%` } as React.CSSProperties}
className="w-[var(--progress)]"


Pitfall 2: Class Conflicts Without cn()

Tailwind classes can conflict. The last class in source order does NOT win — CSS specificity and stylesheet order determine the result.

ANTI_PATTERN: Overriding without cn().

// BROKEN — both bg classes exist, unpredictable winner
<Button className={`bg-primary ${isActive ? "bg-green-500" : ""}`} />

FIX: Always use cn() (which calls tailwind-merge) to resolve conflicts.

// CORRECT — tailwind-merge deduplicates intelligently
<Button className={cn("bg-primary", isActive && "bg-green-500")} />

CHECK: Anywhere className is built from multiple sources. THEN: Use cn() — never string concatenation, never template literals.


Pitfall 3: Dark Mode Default Changed in v4

Tailwind v4 defaults to prefers-color-scheme: dark (media query). The dark: variant uses the OS preference, NOT a .dark class on <html>.

ANTI_PATTERN: Assuming dark: works with a class toggle.

// BROKEN in v4 default — toggling class has no effect
document.documentElement.classList.toggle("dark")

FIX: For GE projects using data-theme attribute switching:

@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

CHECK: The project supports user-controlled dark mode. THEN: Verify @custom-variant dark is configured in the main CSS file. IF: Only OS-preference dark mode is needed. THEN: The v4 default is correct — no configuration needed.


Pitfall 4: Border Color Default Changed

In v4, the default border color is currentColor (was gray-200 in v3). Adding border without specifying a color produces borders matching text color.

ANTI_PATTERN: Using border alone expecting a light gray border.

{/* v4: border will be currentColor (black on light bg) — not gray-200 */}
<div className="border rounded-lg">

FIX: Always specify border color explicitly.

<div className="border border-border rounded-lg">


Pitfall 5: @apply Ordering Issues

@apply in v4 works differently with cascade layers. Classes applied via @apply may have different specificity than expected.

ANTI_PATTERN: Using @apply for complex component styles. FIX: Prefer CVA + cn() for component variants. Reserve @apply for truly simple cases (base body styles, prose overrides).

CHECK: You are reaching for @apply. IF: It is for a component with variants. THEN: Use CVA instead. IF: It is for a global base style (body, headings in prose). THEN: @apply is acceptable.


Pitfall 6: Gradient Syntax Changed

v4 renames gradient utilities.

ANTI_PATTERN: Using v3 gradient syntax.

{/* v3 syntax — does not work in v4 */}
<div className="bg-gradient-to-r from-blue-500 to-purple-500">

FIX: Use v4 syntax.

{/* v4 syntax */}
<div className="bg-linear-to-r from-blue-500 to-purple-500">


Pitfall 7: Modifying components/ui/ Directly

shadcn components in components/ui/ are the upgrade-safe base. Editing them means losing changes when re-installing or updating.

ANTI_PATTERN: Adding project-specific logic to components/ui/button.tsx. FIX: Create a wrapper: components/app-button.tsx that imports from ui/button.

CHECK: A change is being made to a file in components/ui/. IF: The change is project-specific behavior (loading states, analytics, custom variants). THEN: Create a wrapper component instead. IF: The change is fixing a shadcn bug or updating the base token mapping. THEN: Editing components/ui/ is acceptable — document the change.


Pitfall 8: Zod v4 Error Handling

GE uses Zod for form validation with shadcn Form components.

ANTI_PATTERN: Accessing .errors on a ZodError. FIX: Use .issues — Zod v4 removed .errors.

// BROKEN in Zod v4
try { schema.parse(data) } catch (e) { console.log(e.errors) }

// CORRECT
try { schema.parse(data) } catch (e) { console.log(e.issues) }

Pitfall 9: color-mix() Browser Support

Tailwind v4 uses color-mix() for opacity utilities (e.g., bg-primary/50). This requires Safari 16.4+, Chrome 111+, Firefox 128+.

CHECK: The project targets older browsers. IF: Safari < 16.4 or Chrome < 111 is required. THEN: Do not use Tailwind v4 — stay on v3.4.


Pitfall 10: Server Component Styling

shadcn components using Radix primitives require "use client" because Radix uses React context and event handlers.

CHECK: A shadcn component is used in a Server Component file. IF: The component uses Radix (Dialog, Dropdown, Popover, etc.). THEN: Import from a Client Component wrapper or add "use client" to the file. IF: The component is purely presentational (Card, Badge, Separator). THEN: It can be used directly in Server Components.


Pitfall 11: Missing cn() Helper

The cn() function must exist at lib/utils.ts.

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

CHECK: The project is set up. THEN: Verify lib/utils.ts exists with the cn() function. IF: Missing. THEN: Create it — every component depends on it.


Pitfall 12: Lucide Icon Sizing

Lucide icons have no default size. They render at 24x24 if no size is set. In dense UIs this is often too large.

ANTI_PATTERN: Using Lucide icons without size classes.

<Search /> {/* Renders 24x24 — often too large */}

FIX: Always set explicit size.

<Search className="h-4 w-4" /> {/* 16px — standard inline icon */}


Cross-References

READ_ALSO: wiki/docs/stack/tailwind-shadcn/index.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/design-tokens.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/component-patterns.md READ_ALSO: wiki/docs/stack/tailwind-shadcn/checklist.md READ_ALSO: wiki/docs/stack/nextjs/index.md