shadcn/ui is a collection of accessible React components built on Radix UI primitives — you copy components into your project and own them, rather than installing a component library. Components use cva (class-variance-authority) to define variants. CSS variables in globals.css control the full theme: --background, --foreground, --primary, --muted, --destructive, in HSL format for runtime dark mode. Form wraps React Hook Form with Zod validation — FormField, FormControl, FormMessage wire the error state. DataTable composes TanStack Table with useReactTable, column definitions, and flexRender. Combobox combines Popover + Command for searchable selects. npx shadcn@latest add button pulls the component with its dependencies. Claude Code generates shadcn UI component compositions, themed design systems, form implementations, and custom registry components adapted to your brand.
CLAUDE.md for shadcn/ui
## shadcn/ui Stack
- Version: shadcn@latest (components.json), Radix UI, tailwindcss >= 3.4
- Theme: CSS variables in globals.css — --background/--foreground/--primary in HSL with dark: override
- Variants: cva(base, { variants: {}, defaultVariants: {} }) — extends base components
- Forms: FormField + FormControl + FormMessage + zodResolver — wires RHF to Zod schema
- Table: DataTable with useReactTable — ColumnDef<T>[], getCoreRowModel, getSortedRowModel
- Combobox: Popover + Command + CommandInput + CommandItem — searchable select
- Add: npx shadcn@latest add [component] — copies to src/components/ui/
Theme Configuration
/* app/globals.css — CSS variable theme */
@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: 221.2 83.2% 53.3%;
--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: 221.2 83.2% 53.3%;
--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%;
--primary: 217.2 91.2% 59.8%;
--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%;
--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: 224.3 76.3% 48%;
}
}
Extended Button Variants
// components/ui/button.tsx — extended with more variants
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Loader2 } from "lucide-react"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap 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",
// Custom variants
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-amber-500 text-white hover:bg-amber-600",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
xl: "h-12 rounded-lg px-10 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</Comp>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
Form with Zod Validation
// components/forms/CreateOrderForm.tsx — shadcn Form + React Hook Form + Zod
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
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"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/use-toast"
const createOrderSchema = z.object({
customerEmail: z.string().email("Invalid email address"),
shippingLine1: z.string().min(1, "Required").max(100),
shippingCity: z.string().min(1, "Required"),
shippingCountry: z.string().length(2, "Must be a 2-letter code"),
priority: z.enum(["standard", "express", "overnight"]),
notes: z.string().max(500).optional(),
})
type CreateOrderValues = z.infer<typeof createOrderSchema>
export function CreateOrderForm({ onSuccess }: { onSuccess: () => void }) {
const form = useForm<CreateOrderValues>({
resolver: zodResolver(createOrderSchema),
defaultValues: {
customerEmail: "",
shippingLine1: "",
shippingCity: "",
shippingCountry: "US",
priority: "standard",
},
})
async function onSubmit(values: CreateOrderValues) {
try {
const response = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
})
if (!response.ok) throw new Error("Failed to create order")
toast({ title: "Order created", description: "Your order has been placed." })
onSuccess()
} catch {
toast({ title: "Error", description: "Failed to create order.", variant: "destructive" })
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="customerEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Customer Email</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Shipping Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shipping speed" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="standard">Standard (5–7 days)</SelectItem>
<SelectItem value="express">Express (2–3 days)</SelectItem>
<SelectItem value="overnight">Overnight</SelectItem>
</SelectContent>
</Select>
<FormDescription>Faster shipping incurs additional charges.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={form.formState.isSubmitting} className="w-full">
Place Order
</Button>
</form>
</Form>
)
}
DataTable with TanStack Table
// components/orders/OrdersTable.tsx — DataTable with sorting and filtering
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table"
import { useState } from "react"
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { ArrowUpDown } from "lucide-react"
import type { Order } from "@/types"
const statusColors = {
pending: "secondary",
processing: "default",
shipped: "outline",
delivered: "success",
cancelled: "destructive",
} as const
export const columns: ColumnDef<Order>[] = [
{
accessorKey: "id",
header: "Order ID",
cell: ({ row }) => (
<span className="font-mono text-sm">#{row.getValue<string>("id").slice(-8)}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant={statusColors[row.getValue<Order["status"]>("status")]}>
{row.getValue("status")}
</Badge>
),
},
{
accessorKey: "totalCents",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Total
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span>${(row.getValue<number>("totalCents") / 100).toFixed(2)}</span>
),
},
{
accessorKey: "createdAt",
header: "Date",
cell: ({ row }) => (
<span className="text-muted-foreground text-sm">
{new Date(row.getValue("createdAt")).toLocaleDateString()}
</span>
),
},
]
export function OrdersTable({ data }: { data: Order[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState("")
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<div className="space-y-4">
<Input
placeholder="Search orders..."
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
className="max-w-sm"
/>
<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.length ? (
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>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No orders found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<p className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} orders
</p>
</div>
)
}
For the Radix UI primitives underlying shadcn when you need headless unstyled components to build a completely custom design system without the Tailwind CSS dependency, see the React component patterns guide for Radix composition. For the Mantine UI library alternative that provides a more opinionated out-of-the-box styled component library with built-in form hooks and dark mode, the React state management guide covers component integration patterns. The Claude Skills 360 bundle includes shadcn/ui skill sets covering theming, DataTable, and form patterns. Start with the free tier to try shadcn component generation.