Post

Styling with Tailwind CSS v4 & shadcn/ui

Styling with Tailwind CSS v4 & shadcn/ui

Series Overview

  1. TypeScript Patterns for React
  2. Type-Safe Routing with TanStack Router
  3. Server State with TanStack Query
  4. Styling with Tailwind CSS v4 & shadcn/ui (this post)
  5. Modular Feature Architecture
  6. Deploy to AWS with S3, CloudFront & CDK

Introduction

A modern React SPA needs a design system that is fast, themeable, and accessible without requiring you to build every component from scratch. Tailwind CSS v4 and shadcn/ui together provide exactly that.

Tailwind CSS v4 is a ground-up rewrite with a new high-performance engine (5x faster full builds), zero-configuration setup, a first-party Vite plugin, automatic content detection, and CSS-first configuration – no more tailwind.config.js required by default.

shadcn/ui is not a component library you install as a dependency. Instead, it is a collection of beautifully designed, accessible components built on Radix UI primitives that you copy directly into your project. You own the code – no version lock-in, no hidden abstractions.

This post walks through the complete setup: installing Tailwind v4, integrating shadcn/ui, theming with CSS variables, implementing dark mode, building custom components, and organizing your component files.

Installing Tailwind CSS v4

Step 1: Install Packages

From your Vite + React + TypeScript project root:

1
npm install tailwindcss @tailwindcss/vite

Tailwind v4 has zero peer dependencies – no PostCSS, no autoprefixer, no tailwind.config.js needed for standard setups.

Step 2: Add the Vite Plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// vite.config.ts
import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Step 3: Add the CSS Import

Replace the contents of src/index.css with a single line:

1
@import "tailwindcss";

That is the entire Tailwind setup. The v3 directives (@tailwind base; @tailwind components; @tailwind utilities;) are no longer needed. In v4, @import "tailwindcss" does everything.

Step 4: Verify

1
2
3
4
5
6
7
function App() {
  return (
    <h1 className="text-3xl font-bold text-blue-600 p-8">
      Tailwind is working!
    </h1>
  );
}

The Vite plugin is the recommended path for Vite projects. It provides tighter integration and better performance than the PostCSS plugin. Only use @tailwindcss/postcss if your framework requires PostCSS.

Installing shadcn/ui

Step 1: Configure Path Aliases

shadcn/ui requires the @/ import alias. Update both tsconfig files:

tsconfig.json:

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

tsconfig.app.json:

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Install @types/node for the path import in vite config:

1
npm install -D @types/node

Step 2: Run the shadcn CLI

1
npx shadcn@latest init

The CLI will ask you to choose a base color (Neutral, Stone, Zinc, etc.). It creates several files:

  1. components.json – Configuration file at the project root
  2. src/lib/utils.ts – The cn() helper function
  3. Updated src/index.css – CSS variables for the theme
  4. Dependencies installedclsx, tailwind-merge, class-variance-authority, lucide-react

The cn() Helper

1
2
3
4
5
6
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

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

cn() combines clsx (conditional class names) and tailwind-merge (deduplicates conflicting Tailwind classes). Without it, className="p-4 p-8" would apply both padding values. With twMerge, the last one wins. This is essential for components that accept className props.

Adding Components

shadcn/ui components are added one at a time. Each command copies the component source code into your project:

1
2
3
4
5
6
7
8
9
10
11
# Add individual components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add input
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add toast
npx shadcn@latest add form

# Add all components at once
npx shadcn@latest add --all

Components are placed in src/components/ui/. Using them is straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Button } from '@/components/ui/button';
import {
  Card, CardContent, CardDescription, CardHeader, CardTitle,
} from '@/components/ui/card';

function Dashboard() {
  return (
    <Card className="w-96">
      <CardHeader>
        <CardTitle>Welcome</CardTitle>
        <CardDescription>Your dashboard overview</CardDescription>
      </CardHeader>
      <CardContent>
        <p className="mb-4 text-muted-foreground">
          Everything is running smoothly.
        </p>
        <Button>Get Started</Button>
        <Button variant="outline" className="ml-2">Learn More</Button>
      </CardContent>
    </Card>
  );
}

Because shadcn/ui copies actual source code into your project, you can modify any component freely. The tradeoff is that you maintain the code yourself.

Available Component Categories

CategoryComponents
LayoutCard, Separator, Aspect Ratio, Resizable, Collapsible
NavigationNavigation Menu, Menubar, Breadcrumb, Pagination, Sidebar, Tabs
FormsButton, Input, Textarea, Checkbox, Radio Group, Select, Switch, Slider, Form, Label
Data DisplayTable, Data Table, Badge, Avatar, Calendar, Chart
FeedbackAlert, Alert Dialog, Dialog, Drawer, Sheet, Toast (Sonner), Tooltip, Popover
OverlayDialog, Sheet, Drawer, Dropdown Menu, Context Menu, Command

Theming with CSS Variables

shadcn/ui uses semantic CSS variables that map to Tailwind utility classes. The :root selector defines light theme values, .dark defines dark theme values, and @theme inline registers them with Tailwind v4.

The Token Convention

Every color has a surface and foreground pair:

TokenTailwind ClassPurpose
background / foregroundbg-background / text-foregroundPage background and default text
primary / primary-foregroundbg-primary / text-primary-foregroundBrand/CTA buttons
secondary / secondary-foregroundbg-secondary / text-secondary-foregroundSecondary actions
muted / muted-foregroundbg-muted / text-muted-foregroundSubtle backgrounds, de-emphasized text
accent / accent-foregroundbg-accent / text-accent-foregroundHover/active states
card / card-foregroundbg-card / text-card-foregroundCard surfaces
destructivebg-destructiveDanger actions
border / input / ringborder-border / border-input / ring-ringBorders and focus rings

OKLCH Colors

Tailwind v4 + shadcn/ui now use OKLCH colors by default. OKLCH is perceptually uniform – two colors with the same lightness value actually look equally light, unlike HSL where perceived brightness varies wildly between hues. This makes generating consistent color scales much easier.

Customizing the Theme

To change your brand color, modify the --primary variable:

1
2
3
4
5
6
7
8
:root {
  --primary: oklch(0.55 0.2 250);          /* Blue brand */
  --primary-foreground: oklch(0.985 0 0);  /* White text on blue */
}
.dark {
  --primary: oklch(0.7 0.18 250);          /* Lighter blue for dark mode */
  --primary-foreground: oklch(0.15 0 0);   /* Dark text on lighter blue */
}

Adding a Custom Token

1
2
3
4
5
6
7
8
9
10
11
12
13
:root {
  --warning: oklch(0.84 0.16 84);
  --warning-foreground: oklch(0.28 0.07 46);
}
.dark {
  --warning: oklch(0.41 0.11 46);
  --warning-foreground: oklch(0.99 0.02 95);
}

@theme inline {
  --color-warning: var(--warning);
  --color-warning-foreground: var(--warning-foreground);
}

Now bg-warning and text-warning-foreground work in your components. Use the shadcn/ui theme generator to create a complete palette from a single brand color.

Dark Mode

Step 1: Install a Theme Provider

For SPAs, use next-themes (which works with any React framework despite the name):

1
npm install next-themes

Step 2: Create a ThemeProvider Wrapper

1
2
3
4
5
6
7
8
9
10
// src/components/theme-provider.tsx
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ComponentProps } from 'react';

export function ThemeProvider({
  children,
  ...props
}: ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

Step 3: Wrap Your App

1
2
3
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
  <App />
</ThemeProvider>

Step 4: Create a Theme Toggle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu, DropdownMenuContent,
  DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

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

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <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" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

How Dark Mode Works

  1. next-themes adds/removes the .dark class on <html>
  2. CSS variables under .dark { ... } override :root values
  3. @custom-variant dark (&:is(.dark *)) tells Tailwind v4 that dark: utilities should match when .dark is on an ancestor
  4. All components automatically re-theme – no per-component changes needed

Building Custom Components

Wrapping shadcn/ui Components

Do not modify files in src/components/ui/ directly for app-specific behavior. Instead, create wrapper components:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/shared/components/SubmitButton.tsx
import { Button, type ButtonProps } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';

interface SubmitButtonProps extends ButtonProps {
  isLoading?: boolean;
}

export function SubmitButton({
  children, isLoading, disabled, className, ...props
}: SubmitButtonProps) {
  return (
    <Button
      disabled={disabled || isLoading}
      className={cn('min-w-24', className)}
      {...props}
    >
      {isLoading ? (
        <>
          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
          Loading...
        </>
      ) : (
        children
      )}
    </Button>
  );
}

Forms with shadcn/ui + react-hook-form + Zod

1
2
npx shadcn@latest add form input
npm install zod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
  Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';

const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Must be at least 8 characters'),
});

type LoginValues = z.infer<typeof loginSchema>;

export function LoginForm({ onSubmit }: { onSubmit: (data: LoginValues) => void }) {
  const form = useForm<LoginValues>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: '', password: '' },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit" className="w-full">Sign In</Button>
      </form>
    </Form>
  );
}

File Organization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
src/
├── components/
│   └── ui/                    # shadcn/ui primitives (rarely modified)
│       ├── button.tsx
│       ├── card.tsx
│       └── ...
├── shared/
│   └── components/            # App-specific shared components (wrappers)
│       ├── SubmitButton.tsx
│       ├── StatCard.tsx
│       └── ThemeToggle.tsx
├── features/
│   └── auth/
│       └── components/        # Feature-specific components
│           ├── LoginForm.tsx
│           └── RegisterForm.tsx
└── lib/
    └── utils.ts               # cn() helper

Treat src/components/ui/ as a pseudo-library. When you need to customize behavior, create a wrapper in shared/components/. This way, you can re-run npx shadcn@latest add button --overwrite to pick up upstream improvements without losing your customizations.

Common Patterns

Data Tables

1
2
npx shadcn@latest add table
npm install @tanstack/react-table

shadcn/ui provides a Data Table recipe integrating @tanstack/react-table with sorting, filtering, pagination, and row selection.

Toast Notifications

1
npx shadcn@latest add sonner
1
2
3
import { toast } from 'sonner';
toast.success('Post created successfully');
toast.error('Failed to save changes');

Add <Toaster /> once in your root layout.

Command Palette

1
npx shadcn@latest add command dialog

The Command component provides a searchable command palette similar to VS Code’s command palette.

Conclusion

Tailwind CSS v4 and shadcn/ui together provide a complete design system for React SPAs. Tailwind handles the utility layer with zero configuration, shadcn/ui gives you accessible component patterns you fully own, and CSS variables control the entire theme including dark mode. The setup is remarkably simple – a Vite plugin, a CLI command, and you are ready to build.

In the next post, we will organize all these pieces into a modular feature architecture that scales from small projects to large team codebases.

References

This post is licensed under CC BY 4.0 by the author.