Overview
shadcn/ui v4 switched from Radix UI to Base UI (@base-ui/react) under the hood. The most important change is how composition works.
Critical: render Prop, Not asChild
// ✅ Correct — shadcn/ui v4 (Base UI)
<Button render={<Link href="/dashboard" />}>Go to Dashboard</Button>
// ❌ Wrong — this is Radix UI / shadcn v3 syntax
<Button asChild><Link href="/dashboard">Go to Dashboard</Link></Button>
This is the most common mistake when moving to shadcn/ui v4. asChild does not exist in Base UI components.
Component Installation
npx shadcn@latest add button dialog select
Components are installed to components/ui/. Do not barrel-export them — import directly:
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
Styling Conventions
- Use
cn()from@/lib/utilsfor conditional classes:import { cn } from "@/lib/utils"; <div className={cn("rounded-xl p-4", isActive && "ring-2 ring-primary")} /> - Use Tailwind utility classes — do not create custom CSS for component styling
- Respect existing design tokens in
globals.css - Generated
components/ui/files are exempt from file-size limits
Patterns
Dialog with Form
<Dialog>
<DialogTrigger render={<Button />}>Open</DialogTrigger>
<DialogContent>
<form action={submitAction}>
{/* form fields */}
</form>
</DialogContent>
</Dialog>
Select with Controlled Value
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger>
<SelectValue placeholder="Choose..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
<SelectItem value="b">Option B</SelectItem>
</SelectContent>
</Select>
Do Not
- Use
asChild— it does not exist in Base UI - Create new button styles — use
variantprop on<Button> - Barrel-export from
components/ui/— import each component directly - Override shadcn component internals unless absolutely necessary