Skip to main content
Technology & EngineeringUi Components Services366 lines

Shadcn UI

"shadcn/ui: copy-paste components, CLI installation, theming with CSS variables, dark mode, component customization, form integration, data tables, command palette"

Quick Summary18 lines
shadcn/ui is not a component library you install as a dependency. It is a collection of reusable components that you copy and paste into your project. You own the code. You can customize every component to fit your needs. Components are built on top of Radix UI primitives and styled with Tailwind CSS. The CLI automates adding components, but the result is source code in your repository that you control completely.

## Key Points

- Components live in your codebase, not in node_modules
- Built on Radix UI for accessibility
- Styled with Tailwind CSS and CSS variables for theming
- Every component is a starting point, not a final product
1. **Use the CLI to add components** rather than copying code manually. The CLI resolves dependencies and sets up imports correctly.
2. **Customize at the CSS variable level** for global theme changes. Edit individual component files for structural changes.
3. **Compose components** by combining primitives. A card with a form inside uses Card, Form, Input, Button together.
4. **Keep component files in `ui/`** and build feature components that import from `ui/`. Never modify `ui/` components for feature-specific logic; wrap them instead.
5. **Use `asChild` prop** to merge shadcn component behavior onto your own elements, avoiding extra DOM nodes.
6. **Leverage `cva` (class-variance-authority)** for creating component variants that are consistent with the design system.
7. **Update components** by re-running the CLI add command. Diff the changes against your customizations.
1. **Installing shadcn/ui as an npm package** -- it is not meant to be consumed as a dependency. You copy the source code into your project.
skilldb get ui-components-services-skills/Shadcn UIFull skill: 366 lines
Paste into your CLAUDE.md or agent config

shadcn/ui Skill

Core Philosophy

shadcn/ui is not a component library you install as a dependency. It is a collection of reusable components that you copy and paste into your project. You own the code. You can customize every component to fit your needs. Components are built on top of Radix UI primitives and styled with Tailwind CSS. The CLI automates adding components, but the result is source code in your repository that you control completely.

Key principles:

  • Components live in your codebase, not in node_modules
  • Built on Radix UI for accessibility
  • Styled with Tailwind CSS and CSS variables for theming
  • Every component is a starting point, not a final product

Setup

Project Initialization

# Initialize shadcn/ui in an existing Next.js/Vite project
npx shadcn@latest init

# Answer prompts for:
# - TypeScript: yes
# - Style: Default or New York
# - Base color: Slate, Gray, Zinc, Neutral, Stone
# - CSS variables for colors: yes
# - tailwind.config location
# - Components alias: @/components
# - Utils alias: @/lib/utils

Adding Components

# Add individual components
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add form

# Add multiple components at once
npx shadcn@latest add button card dialog dropdown-menu

# View all available components
npx shadcn@latest add

Project Structure

src/
  components/
    ui/
      button.tsx        # shadcn components live here
      dialog.tsx
      input.tsx
      form.tsx
  lib/
    utils.ts            # cn() utility function

The cn() Utility

// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

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

Key Techniques

Theming with CSS Variables

/* app/globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-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%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... remaining dark mode variables */
  }
}

Dark Mode Toggle

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

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-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] 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>
  );
}

Form Integration with React Hook Form and 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";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";

const profileSchema = z.object({
  username: z.string().min(2).max(30),
  email: z.string().email(),
  role: z.enum(["admin", "user", "editor"]),
});

type ProfileValues = z.infer<typeof profileSchema>;

export function ProfileForm() {
  const form = useForm<ProfileValues>({
    resolver: zodResolver(profileSchema),
    defaultValues: { username: "", email: "", role: "user" },
  });

  function onSubmit(data: ProfileValues) {
    console.log(data);
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="johndoe" {...field} />
              </FormControl>
              <FormDescription>Your public display name.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Role</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger><SelectValue placeholder="Select a role" /></SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="admin">Admin</SelectItem>
                  <SelectItem value="user">User</SelectItem>
                  <SelectItem value="editor">Editor</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Save Profile</Button>
      </form>
    </Form>
  );
}

Data Table with TanStack Table

"use client";

import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";

interface Payment {
  id: string;
  amount: number;
  status: "pending" | "processing" | "success" | "failed";
  email: string;
}

const columns: ColumnDef<Payment>[] = [
  { accessorKey: "email", header: "Email" },
  { accessorKey: "status", header: "Status" },
  {
    accessorKey: "amount",
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"));
      const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
      return <div className="text-right font-medium">{formatted}</div>;
    },
  },
];

export function DataTable({ data }: { data: Payment[] }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <div>
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.map((row) => (
              <TableRow key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
                ))}
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-end space-x-2 py-4">
        <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
        <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
      </div>
    </div>
  );
}

Command Palette (cmdk)

"use client";

import { useEffect, useState } from "react";
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command";
import { Calculator, Calendar, CreditCard, Settings, User } from "lucide-react";

export function CommandPalette() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((prev) => !prev);
      }
    };
    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>
        <CommandGroup heading="Suggestions">
          <CommandItem><Calendar className="mr-2 h-4 w-4" /> Calendar</CommandItem>
          <CommandItem><Calculator className="mr-2 h-4 w-4" /> Calculator</CommandItem>
        </CommandGroup>
        <CommandSeparator />
        <CommandGroup heading="Settings">
          <CommandItem><User className="mr-2 h-4 w-4" /> Profile</CommandItem>
          <CommandItem><CreditCard className="mr-2 h-4 w-4" /> Billing</CommandItem>
          <CommandItem><Settings className="mr-2 h-4 w-4" /> Settings</CommandItem>
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  );
}

Best Practices

  1. Use the CLI to add components rather than copying code manually. The CLI resolves dependencies and sets up imports correctly.
  2. Customize at the CSS variable level for global theme changes. Edit individual component files for structural changes.
  3. Compose components by combining primitives. A card with a form inside uses Card, Form, Input, Button together.
  4. Keep component files in ui/ and build feature components that import from ui/. Never modify ui/ components for feature-specific logic; wrap them instead.
  5. Use asChild prop to merge shadcn component behavior onto your own elements, avoiding extra DOM nodes.
  6. Leverage cva (class-variance-authority) for creating component variants that are consistent with the design system.
  7. Update components by re-running the CLI add command. Diff the changes against your customizations.

Anti-Patterns

  1. Installing shadcn/ui as an npm package -- it is not meant to be consumed as a dependency. You copy the source code into your project.
  2. Overriding styles with !important -- use the cn() utility to merge Tailwind classes properly. The last class wins via tailwind-merge.
  3. Ignoring the Form component and wiring up react-hook-form manually. The Form component provides accessible error handling and label association out of the box.
  4. Hardcoding colors instead of using CSS variables -- this breaks theming and dark mode. Always reference semantic tokens like bg-primary or text-muted-foreground.
  5. Creating deeply nested component wrappers instead of composing at the usage site. shadcn components are designed for flat, explicit composition.
  6. Skipping accessibility by removing Radix primitives and replacing them with plain divs. The Radix layer provides keyboard navigation, focus management, and screen reader support.

Install this skill directly: skilldb add ui-components-services-skills

Get CLI access →