When to Use
- Use when: adding shadcn/ui to a project for the first time
- Use when: extending a shadcn component with new variants
- Use when: theming shadcn to match a brand (CSS variables)
- Use when: building a form with shadcn Form + react-hook-form + zod
- Do NOT use for: building a full token system from scratch — design-system-gen
- Do NOT use for: non-Tailwind component libraries
Setup
# New project
npx shadcn@latest init
# Add components — copies source into your repo (you own the code)
npx shadcn@latest add button
npx shadcn@latest add dialog form input label select toast
# Update a component to latest shadcn version
npx shadcn@latest add button --overwrite
Components land in components/ui/ — they're yours, modify freely.
CSS Variable Theming
/* globals.css — customize to match brand */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222 84% 5%;
--primary: 221 83% 53%; /* blue-600 */
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222 47% 11%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--destructive: 0 84% 60%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 221 83% 53%;
--radius: 0.5rem;
}
.dark {
--background: 222 84% 5%;
--foreground: 210 40% 98%;
--primary: 217 91% 60%;
--primary-foreground: 222 47% 11%;
--border: 217 33% 17%;
/* ... */
}
}
Extending Components with cva()
// components/ui/button.tsx — add a new "ghost-destructive" variant
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50 disabled:pointer-events-none',
{
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 hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
'ghost-destructive': 'hover:bg-destructive/10 hover:text-destructive', // NEW
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 px-3 rounded-md',
lg: 'h-11 px-8 rounded-md',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
// Usage
<Button variant="ghost-destructive" size="sm">Delete</Button>
shadcn Form (react-hook-form + zod)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
});
type FormValues = z.infer<typeof schema>;
function LoginForm() {
const form = useForm<FormValues>({ resolver: zodResolver(schema) });
return (
<Form {...form}>
<form className="space-y-4">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage /> {/* auto-shows zod error */}
</FormItem>
)} />
<Button type="submit">Login</Button>
</form>
</Form>
);
}
Composing Radix Primitives
// shadcn Dialog — slots map to Radix Dialog.* primitives
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog';
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button> {/* asChild renders Trigger as Button, not extra DOM */}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm delete</DialogTitle>
<DialogDescription>This action is permanent.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="destructive">Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Anti-Fake-Pass Rules
Before claiming shadcn/ui implementation is done, you MUST show:
- Components added via
npx shadcn@latest add— not manually copied - Theme uses CSS variables in
globals.css— no hardcoded Tailwind colors - Dark mode wired:
.darkCSS variable block +ThemeProvideror class toggle - Button/Badge variants use
cva()— not ad-hoccn()branching - Forms use
shadcn/ui Form+zodResolver— not raw<input>+ manual validation -
asChildused onDialogTrigger/TooltipTriggerto avoid extra DOM node -
FormMessagepresent in every form field — errors are surfaced
Reference: gates/anti-fake-pass-gate.md