shadcn/ui Complete Guide | Radix UI, Tailwind, Copy-Paste Components

shadcn/ui Complete Guide | Radix UI, Tailwind, Copy-Paste Components

이 글의 핵심

shadcn/ui is a collection of reusable components built with Radix UI and Tailwind CSS. Unlike traditional libraries, you copy components directly into your project for full control and customization.

Introduction

shadcn/ui is not a traditional component library. Instead of installing an npm package, you copy components directly into your project. This approach gives you complete control over the code while providing beautifully designed, accessible components.

Why shadcn/ui?

Traditional Libraries (Material-UI, Ant Design):

  • Large bundle size (import everything)
  • Difficult to customize deeply
  • Fighting the framework’s opinions
  • Dependencies you can’t control

shadcn/ui Approach:

  • Copy only what you need
  • Own and modify the code
  • Built on Radix UI (accessibility)
  • Styled with Tailwind (flexibility)

1. Installation & Setup

Prerequisites

# Requires Node.js 18+
node --version

# Create Next.js project (recommended)
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app

Initialize shadcn/ui

npx shadcn-ui@latest init

Configuration prompts:

✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Would you like to use CSS variables for colors? › yes

Generated Files

my-app/
├── components/
│   └── ui/           # Components go here
├── lib/
│   └── utils.ts      # Utility functions (cn helper)
├── tailwind.config.ts
└── components.json   # shadcn/ui config

components.json

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

2. Adding Components

Button Component

npx shadcn-ui@latest add button

This copies the Button component to components/ui/button.tsx.

Usage:

// app/page.tsx
import { Button } from '@/components/ui/button';

export default function Home() {
  return (
    <div className="p-8 space-x-4">
      <Button>Default</Button>
      <Button variant="destructive">Delete</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
      
      <Button size="sm">Small</Button>
      <Button size="lg">Large</Button>
      <Button size="icon">🚀</Button>
    </div>
  );
}

Card Component

npx shadcn-ui@latest add card
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';

export default function ProductCard() {
  return (
    <Card className="w-[350px]">
      <CardHeader>
        <CardTitle>Product Name</CardTitle>
        <CardDescription>Product description goes here</CardDescription>
      </CardHeader>
      <CardContent>
        <p>Detailed product information...</p>
      </CardContent>
      <CardFooter className="flex justify-between">
        <Button variant="outline">Cancel</Button>
        <Button>Buy Now</Button>
      </CardFooter>
    </Card>
  );
}

Form Components

npx shadcn-ui@latest add input label
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export default function LoginForm() {
  return (
    <form className="space-y-4">
      <div>
        <Label htmlFor="email">Email</Label>
        <Input id="email" type="email" placeholder="you@example.com" />
      </div>
      
      <div>
        <Label htmlFor="password">Password</Label>
        <Input id="password" type="password" />
      </div>
      
      <Button type="submit" className="w-full">
        Sign In
      </Button>
    </form>
  );
}

3. Form Integration with React Hook Form

npx shadcn-ui@latest add form
npm install react-hook-form @hookform/resolvers zod
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';

const formSchema = z.object({
  username: z.string().min(2, 'Username must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
});

export default function ProfileForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: '',
      email: '',
    },
  });

  function onSubmit(values: z.infer<typeof formSchema>) {
    console.log(values);
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="johndoe" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <Button type="submit">Update profile</Button>
      </form>
    </Form>
  );
}

4. Dialog & Modal

npx shadcn-ui@latest add dialog
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export default function UserDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>Edit Profile</Button>
      </DialogTrigger>
      
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Make changes to your profile here. Click save when you're done.
          </DialogDescription>
        </DialogHeader>
        
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              Name
            </Label>
            <Input id="name" defaultValue="Pedro Duarte" className="col-span-3" />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="username" className="text-right">
              Username
            </Label>
            <Input id="username" defaultValue="@peduarte" className="col-span-3" />
          </div>
        </div>
        
        <DialogFooter>
          <Button type="submit">Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

5. Data Tables

npx shadcn-ui@latest add table
npm install @tanstack/react-table
'use client';

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';

const invoices = [
  { id: 'INV001', status: 'Paid', amount: '$250.00' },
  { id: 'INV002', status: 'Pending', amount: '$150.00' },
  { id: 'INV003', status: 'Unpaid', amount: '$350.00' },
];

export default function InvoiceTable() {
  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableCell className="font-medium">{invoice.id}</TableCell>
            <TableCell>{invoice.status}</TableCell>
            <TableCell className="text-right">{invoice.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

6. Toast Notifications

npx shadcn-ui@latest add toast
'use client';

import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';

export default function ToastDemo() {
  const { toast } = useToast();

  return (
    <Button
      onClick={() => {
        toast({
          title: 'Scheduled: Catch up',
          description: 'Friday, February 10, 2023 at 5:57 PM',
        });
      }}
    >
      Show Toast
    </Button>
  );
}

Toaster Component:

// app/layout.tsx
import { Toaster } from '@/components/ui/toaster';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  );
}

7. Customization

Modify Component Styles

Since you own the code, you can modify components directly:

// components/ui/button.tsx
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        
        // Add your custom variant
        custom: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90',
      },
    },
  }
);

Theme Colors

Edit globals.css:

@layer base {
  :root {
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    /* Add custom colors */
    --custom: 280 100% 70%;
  }

  .dark {
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
  }
}

8. Dark Mode

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" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  );
}

Theme Toggle:

'use client';

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

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

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

9. Server Components

shadcn/ui works with Next.js Server Components:

// app/users/page.tsx (Server Component)
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

async function getUsers() {
  const res = await fetch('https://api.example.com/users');
  return res.json();
}

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
      {users.map((user: any) => (
        <Card key={user.id}>
          <CardHeader>
            <CardTitle>{user.name}</CardTitle>
          </CardHeader>
          <CardContent>
            <p>{user.email}</p>
          </CardContent>
        </Card>
      ))}
    </div>
  );
}

10. Best Practices

1. Use the cn Utility

import { cn } from '@/lib/utils';

<Button className={cn('w-full', isLoading && 'opacity-50')} />

2. Create Composed Components

// components/search-input.tsx
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';

export function SearchInput() {
  return (
    <div className="relative">
      <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
      <Input placeholder="Search..." className="pl-8" />
    </div>
  );
}

3. Organize Components

components/
├── ui/              # shadcn/ui components
├── forms/           # Form-specific components
├── layouts/         # Layout components
└── shared/          # Shared custom components

4. Type Safety

import { type ButtonProps } from '@/components/ui/button';

interface CustomButtonProps extends ButtonProps {
  icon?: React.ReactNode;
}

export function CustomButton({ icon, children, ...props }: CustomButtonProps) {
  return (
    <Button {...props}>
      {icon && <span className="mr-2">{icon}</span>}
      {children}
    </Button>
  );
}

11. Common Patterns

Loading States

import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';

export function LoadingButton() {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <Button disabled={isLoading} onClick={() => setIsLoading(true)}>
      {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {isLoading ? 'Loading...' : 'Submit'}
    </Button>
  );
}

Confirmation Dialog

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from '@/components/ui/alert-dialog';

export function DeleteConfirmation() {
  return (
    <AlertDialog>
      <AlertDialogTrigger asChild>
        <Button variant="destructive">Delete</Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
          <AlertDialogDescription>
            This action cannot be undone.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction onClick={() => console.log('Deleted')}>
            Continue
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

Summary

shadcn/ui revolutionizes how we build UIs in React:

  • Copy-paste components you own and control
  • Built on Radix UI for perfect accessibility
  • Styled with Tailwind for easy customization
  • TypeScript first with excellent type safety
  • Works with Server Components in Next.js

Key Takeaways:

  1. You own the code - modify freely
  2. Only copy what you need - smaller bundles
  3. Built on solid foundations (Radix + Tailwind)
  4. Perfect for design systems
  5. Excellent developer experience

Next Steps:

Resources: