Skip to content

Fix: shadcn/ui Not Working — Components Not Rendering, Styles Missing, or Dark Mode Broken

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix shadcn/ui issues — Tailwind CSS v4 vs v3 configuration, CSS variables, dark mode setup, component installation, cn() utility, and common errors after adding components.

The Problem

After installing shadcn/ui components, they render with no styles:

npx shadcn@latest add button
# Component added but <Button> appears unstyled

Or dark mode doesn’t work despite setting the class:

<html className="dark">
  {/* Dark mode styles don't apply */}
</html>

Or the cn() utility causes a type error:

import { cn } from '@/lib/utils';
// Error: Cannot find module '@/lib/utils'

Or after upgrading Tailwind to v4, shadcn components break:

Error: Cannot apply unknown utility class: bg-background

Why This Happens

shadcn/ui isn’t a component library you install as a package — it’s a CLI that copies component code into your project. Issues arise when the surrounding configuration isn’t set up correctly:

  • CSS variables not injected — shadcn/ui uses CSS custom properties (--background, --foreground, etc.) for theming. These must be added to your global CSS file. Without them, every component that uses bg-background or text-foreground renders with no color.
  • Tailwind content paths miss component files — Tailwind purges unused classes. If your tailwind.config doesn’t scan the right directories, classes used in shadcn components are removed from the build.
  • Dark mode requires class strategy — Tailwind’s darkMode: 'class' applies dark styles when a dark class is on the <html> element. The default media strategy uses the OS preference and ignores the class.
  • Tailwind v4 uses a different config format — shadcn/ui CLI was initially designed for Tailwind v3. Tailwind v4 uses CSS-based configuration (@import "tailwindcss") instead of tailwind.config.js.

The CLI’s copy-paste distribution model is what most people misunderstand. When you run npx shadcn@latest add button, the CLI fetches a TypeScript file from the registry and writes it to components/ui/button.tsx inside your repo. There is no node_modules/shadcn-ui package to upgrade later. That means every customization you make stays yours, but every fix the upstream registry ships requires you to re-run the add command and merge the diff manually. If a component breaks after a Tailwind upgrade, the fix is almost always in the local file the CLI wrote — not in a dependency.

The other tripwire is the layered dependency chain. shadcn components sit on top of Radix UI primitives, which expose unstyled accessible behavior. Radix exposes props like data-state="open" that the shadcn styles target with selectors like data-[state=open]:bg-accent. If Tailwind isn’t scanning the file where those selectors are written, or if you accidentally remove the data-* attribute selectors when copying styles, the visual transitions silently stop. Always treat shadcn as three coordinated systems: the copied component file, the Tailwind utility pipeline, and the Radix primitive underneath.

Fix 1: Run the Init Command Correctly

# Start fresh — let the CLI configure everything
npx shadcn@latest init

# Answer the prompts:
# ✔ Which style would you like to use? › Default
# ✔ Which color would you like to use as the base color? › Slate
# ✔ Would you like to use CSS variables for theming? › yes

This command sets up:

  • components.json — shadcn configuration
  • tailwind.config.ts — with the right content paths and plugins
  • globals.css — with all CSS variables
  • lib/utils.ts — with the cn() function

If you already have a project, verify these files exist:

ls components.json lib/utils.ts app/globals.css
# If any are missing, re-run: npx shadcn@latest init

Fix 2: Configure Tailwind Correctly

// tailwind.config.ts — required configuration for shadcn/ui
import type { Config } from 'tailwindcss';

const config: Config = {
  // REQUIRED: dark mode must use 'class' strategy
  darkMode: ['class'],

  // REQUIRED: include all paths where Tailwind classes are used
  content: [
    './pages/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    './app/**/*.{ts,tsx}',
    './src/**/*.{ts,tsx}',
  ],

  theme: {
    container: {
      center: true,
      padding: '2rem',
      screens: {
        '2xl': '1400px',
      },
    },
    extend: {
      // REQUIRED: CSS variable-based color system
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: 'hsl(var(--primary))',
          foreground: 'hsl(var(--primary-foreground))',
        },
        secondary: {
          DEFAULT: 'hsl(var(--secondary))',
          foreground: 'hsl(var(--secondary-foreground))',
        },
        destructive: {
          DEFAULT: 'hsl(var(--destructive))',
          foreground: 'hsl(var(--destructive-foreground))',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        accent: {
          DEFAULT: 'hsl(var(--accent))',
          foreground: 'hsl(var(--accent-foreground))',
        },
        popover: {
          DEFAULT: 'hsl(var(--popover))',
          foreground: 'hsl(var(--popover-foreground))',
        },
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
      // Required for animations
      keyframes: {
        'accordion-down': {
          from: { height: '0' },
          to: { height: 'var(--radix-accordion-content-height)' },
        },
        'accordion-up': {
          from: { height: 'var(--radix-accordion-content-height)' },
          to: { height: '0' },
        },
      },
      animation: {
        'accordion-down': 'accordion-down 0.2s ease-out',
        'accordion-up': 'accordion-up 0.2s ease-out',
      },
    },
  },
  plugins: [require('tailwindcss-animate')],
};

export default config;

Install required plugins:

npm install tailwindcss-animate

Fix 3: Add CSS Variables to globals.css

/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96.1%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 212.7 26.8% 83.9%;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

Fix 4: Implement Dark Mode Toggling

// With next-themes (recommended for Next.js)
npm install next-themes

// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"      // Adds 'dark' class to <html>
      defaultTheme="system"  // Follow OS preference
      enableSystem
      disableTransitionOnChange
    >
      {children}
    </ThemeProvider>
  );
}

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      {/* suppressHydrationWarning prevents hydration mismatch from theme */}
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

// components/theme-toggle.tsx
'use client';

import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
    >
      <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    </Button>
  );
}

Fix 5: Use cn() and Customize Components

// lib/utils.ts — the cn() utility (created by shadcn init)
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

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

// Install if missing:
// npm install clsx tailwind-merge

Customize shadcn components:

// components/ui/button.tsx — add custom variants
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  // Base classes
  'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background 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',
        // Add custom variant
        success: 'bg-green-600 text-white hover:bg-green-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',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

// Usage
<Button variant="success" size="lg">Save Changes</Button>

Fix 6: Use with Tailwind v4

Tailwind v4 changed the configuration format. For projects using Tailwind v4:

# Check your Tailwind version
npx tailwindcss --version

# If v4, use the new CSS-based config approach
/* globals.css — Tailwind v4 format */
@import "tailwindcss";

/* Tailwind v4 uses @theme instead of tailwind.config.js */
@theme {
  --color-background: hsl(0 0% 100%);
  --color-foreground: hsl(222.2 84% 4.9%);
  --color-primary: hsl(222.2 47.4% 11.2%);
  --color-primary-foreground: hsl(210 40% 98%);
  --color-secondary: hsl(210 40% 96.1%);
  --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
  --color-destructive: hsl(0 84.2% 60.2%);
  --color-border: hsl(214.3 31.8% 91.4%);
  --color-input: hsl(214.3 31.8% 91.4%);
  --color-muted: hsl(210 40% 96.1%);
  --color-muted-foreground: hsl(215.4 16.3% 46.9%);
  --color-accent: hsl(210 40% 96.1%);
  --color-popover: hsl(0 0% 100%);
  --color-card: hsl(0 0% 100%);
  --radius: 0.5rem;
}

/* Dark mode theme override */
.dark {
  --color-background: hsl(222.2 84% 4.9%);
  --color-foreground: hsl(210 40% 98%);
  /* ... rest of dark vars */
}

shadcn with Tailwind v4 — use the canary version:

# Use the shadcn canary that supports Tailwind v4
npx shadcn@canary init

# Add components with canary
npx shadcn@canary add button

shadcn/ui vs Radix, Headless UI, Ark UI, React Aria — When Each Wins

shadcn/ui is one of five widely used approaches to building accessible React components, and the distribution model is the main difference. Choose based on how much code you want in your repo and how strict your accessibility requirements are.

shadcn/ui — copy-paste, you own the code. The CLI writes TypeScript files into your project. You can edit anything, but you also have to maintain anything. Tailwind classes are baked in, so the visual layer ships with the component. Best fit: teams that want full control over styling and a design system that lives inside their repo.

Radix UI — headless primitives, no styles. Radix gives you unstyled accessible behavior (focus management, ARIA, keyboard handling) as a regular npm package you upgrade like any dependency. You add styles yourself, usually with Tailwind or vanilla CSS. shadcn/ui is built on Radix — the difference is that with Radix alone you write the styling code from scratch. Best fit: teams with an existing design system that already owns visual decisions.

Headless UI — Tailwind Labs’ version of Radix. Similar headless API, designed to compose cleanly with Tailwind utility classes. Smaller surface area than Radix (Tabs, Listbox, Combobox, Dialog, Disclosure, Menu, Popover, RadioGroup, Switch, Transition), and the React and Vue implementations are kept in sync. Best fit: Tailwind-only projects that want Tailwind Labs to own the primitives.

Ark UI — multi-framework headless built on Zag.js state machines. Same headless idea as Radix, but the underlying state machines work across React, Vue, and Solid. Component coverage is broader than Headless UI (includes Combobox, DatePicker, Pagination, NumberInput, etc.) and stays consistent across frameworks. Best fit: monorepos that need the same components in multiple frameworks.

React Aria Components — Adobe’s accessibility-first primitives. The most thorough WAI-ARIA implementation of any library on this list, with international support (RTL, complex date pickers, virtualized collections) and the deepest documentation on screen reader behavior. The styling API uses render props and data-attributes similar to Radix. Best fit: enterprise apps with strict accessibility audits.

If you start with shadcn/ui and outgrow it (say, you need a feature not in the registry), you can usually drop down to Radix directly since shadcn components are thin wrappers. If you need richer primitives than Radix ships (calendar, complex combobox), Ark UI and React Aria become more appealing than maintaining your own. For pure Tailwind shops without strict a11y requirements, dropping shadcn for plain Radix plus Tailwind often resolves the issue before you switch libraries entirely.

Still Not Working?

Components render but look wrong after updating shadcn — shadcn components are copied into your project. When you run npx shadcn@latest add button on an existing project, it overwrites your local customizations. Check the diff before accepting the overwrite, and back up customized components before updating.

import { Button } from '@/components/ui/button' fails — the @/ alias must be configured in your TypeScript and bundler config:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
// vite.config.ts
import path from 'path';
export default defineConfig({
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
  },
});

Radix UI peer dependency warning — shadcn components use Radix UI primitives. Version mismatches between Radix packages cause subtle bugs. Run npm ls @radix-ui to check versions. If there are conflicts, deduplicate: npm dedupe or reinstall with npm install --legacy-peer-deps. If a specific Radix primitive misbehaves on its own, see Radix UI not working for primitive-level fixes.

Dialog and Sheet content jumps or freezes on open — the most common cause is wrapping your app in a transformed parent (e.g., a CSS transform on <body> for page transitions). Radix portals into <body> by default, and a transformed ancestor breaks position: fixed for the overlay. Remove the transform on <body>, or pass container to the Radix Portal to escape the transformed subtree.

Theme switches flash unstyled content before hydration — the dark mode class is applied after React mounts, so the first paint uses the light theme. Fix this by reading the saved theme in a small inline script in <head> and applying the class before React renders. The next-themes package does this automatically when you set attribute="class" and add suppressHydrationWarning to <html>. If the flash persists, check that no other component is overriding the class on mount. See next-themes not working for theme provider edge cases.

Forms built with shadcn Form lose validation messages on submit — the Form component is a thin wrapper around react-hook-form. If validation errors appear during typing but vanish after submit, the form is being reset by a parent component re-render. Move state up so the form’s parent doesn’t unmount mid-submit, or pass shouldUnregister: false to the form. For broader patterns, see react-hook-form not working.

For related styling issues, see CSS Tailwind not applying.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles