Overview
ShadCN/ui provides accessible, unstyled-by-default components built on Radix UI primitives. The styling convention: named CSS classes in globals.css, not raw Tailwind utilities scattered in TSX files.
Installation
npx shadcn@latest init
# Add components as needed
npx shadcn@latest add button
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add dialog
npx shadcn@latest add table
npx shadcn@latest add select
npx shadcn@latest add card
npx shadcn@latest add badge
npx shadcn@latest add toast
Named CSS Class Convention
Never use raw Tailwind in TSX — extract to named classes in globals.css:
/* globals.css */
/* Page layouts */
.page-container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8; }
.page-header { @apply mb-8 flex items-center justify-between; }
.page-title { @apply text-2xl font-bold text-gray-900; }
/* Cards */
.card-grid { @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6; }
.stat-card { @apply rounded-lg border bg-white p-6 shadow-sm; }
.stat-value { @apply text-3xl font-bold text-gray-900; }
.stat-label { @apply text-sm text-gray-500 mt-1; }
/* Forms */
.form-section { @apply space-y-6; }
.form-field { @apply space-y-2; }
.field-label { @apply text-sm font-medium text-gray-700; }
.field-error { @apply text-sm text-red-600 mt-1; }
/* Tables */
.data-table { @apply w-full border-collapse; }
.table-header-row { @apply border-b bg-gray-50; }
.table-header-cell { @apply px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase; }
.table-row { @apply border-b hover:bg-gray-50 transition-colors; }
.table-cell { @apply px-4 py-3 text-sm text-gray-900; }
/* Buttons */
.btn-primary { @apply bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }
.btn-danger { @apply bg-red-600 hover:bg-red-700 text-white font-medium rounded-md px-4 py-2 transition-colors; }
.btn-ghost { @apply text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md px-3 py-2 transition-colors; }
Form with react-hook-form + Zod
"use client";
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 FormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
});
type FormValues = z.infer<typeof FormSchema>;
export function ContactForm() {
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: { name: "", email: "" },
});
async function onSubmit(values: FormValues) {
const response = await fetch("/api/contacts", {
method: "POST",
body: JSON.stringify(values),
});
if (!response.ok) {
form.setError("root", { message: "Submission failed" });
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="form-section">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="form-field">
<FormLabel className="field-label">Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage className="field-error" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="form-field">
<FormLabel className="field-label">Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage className="field-error" />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Submitting..." : "Submit"}
</Button>
</form>
</Form>
);
}
Data Table with TanStack Table
"use client";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { useState } from "react";
interface Project { id: string; name: string; status: string; createdAt: string; }
const columns: ColumnDef<Project>[] = [
{ accessorKey: "name", header: "Project Name" },
{ accessorKey: "status", header: "Status",
cell: ({ row }) => <span className="badge-status">{row.original.status}</span> },
{ accessorKey: "createdAt", header: "Created",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString() },
];
export function ProjectTable({ data }: { data: Project[] }) {
const [filter, setFilter] = useState("");
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { globalFilter: filter },
onGlobalFilterChange: setFilter,
});
return (
<div className="space-y-4">
<Input
placeholder="Search projects..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="max-w-sm"
/>
<Table className="data-table">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="table-header-row">
{hg.headers.map((h) => (
<TableHead key={h.id} className="table-header-cell">
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="table-row">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="table-cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
Dialog/Modal Pattern
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function ConfirmDeleteDialog({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
</DialogHeader>
<p className="text-sm text-gray-600">This action cannot be undone.</p>
<div className="flex gap-3 mt-4">
<Button variant="destructive" onClick={onConfirm}>Delete</Button>
<Button variant="outline">Cancel</Button>
</div>
</DialogContent>
</Dialog>
);
}
Toast Notifications
// In your root layout, add: <Toaster />
import { Toaster } from "@/components/ui/toaster";
// In components:
import { useToast } from "@/components/ui/use-toast";
function MyComponent() {
const { toast } = useToast();
function handleSuccess() {
toast({
title: "Success",
description: "Project created successfully.",
});
}
function handleError() {
toast({
title: "Error",
description: "Something went wrong.",
variant: "destructive",
});
}
}
Component Variant Pattern (cva)
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
{
variants: {
variant: {
active: "bg-green-100 text-green-800",
inactive: "bg-gray-100 text-gray-800",
pending: "bg-yellow-100 text-yellow-800",
error: "bg-red-100 text-red-800",
},
},
defaultVariants: { variant: "active" },
}
);
interface BadgeProps extends VariantProps<typeof badgeVariants> {
children: React.ReactNode;
className?: string;
}
export function StatusBadge({ variant, children, className }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)}>
{children}
</span>
);
}