kao-shadcn-base-ui
shadcn/ui Component Patterns
Overview
Expert guide for building accessible, customizable UI components with shadcn/ui, Base UI (@base-ui/react), and Tailwind CSS v4.
shadcn/ui is "not a component library — it's how you build your component library." Components are copied into your project as source code you own and can modify freely. This variant uses @base-ui/react as its primitive layer instead of Radix UI.
Important: shadcn/ui wraps Base UI primitives into styled, themed components under @/components/ui/. When building with shadcn/ui, always import from @/components/ui/* — never import directly from @base-ui/react. Even if you also have a Base UI skill available, the shadcn wrappers are the correct abstraction layer for shadcn/ui projects.
When to Use
- Setting up a new project with shadcn/ui
- Building forms with React Hook Form and Zod validation
- Creating accessible UI components (buttons, dialogs, dropdowns, sheets)
- Customizing component styling with Tailwind CSS
- Building Next.js, Vite, TanStack Start, React Router, Laravel, or Astro applications with TypeScript
Constraints and Warnings
- Always import from
@/components/ui/: Never import directly from@base-ui/reactin application code. The shadcn wrappers handle styling, theming, and Base UI composition internally. - Not an NPM Package: Components are copied to your project; you own the code
- Registry Security: Verify the registry source is trusted before installation; review generated component code before production use
- Client Components: Most components require
"use client"directive - Base UI Dependency: Single
@base-ui/reactpackage provides all primitives, but you interact through shadcn wrappers in@/components/ui/ - Never use
asChild: Use therenderprop instead (Base UI, not Radix) renderprop with Button: When usingrenderto replace the underlying<button>with a non-button element (e.g.,<Link>,<a>), passnativeButton={false}. This applies to Button and components built on it:AlertDialogAction,AlertDialogTrigger,DialogTrigger, etc.- Floating components:
Portal > Positioner > Popuppattern is handled internally by shadcn wrappers — you don't compose these layers yourself - Tailwind CSS v4 Required: Components use Tailwind v4 with oklch color format
- Path Aliases: Configure
@alias in tsconfig.json for imports - Tailwind v4 cursor: Default cursor changed to
defaultin Tailwind v4. To restore pointer cursor on buttons, add to your CSS:@layer base { button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; } }
Quick Start
New Project
Use the CLI with --template for framework-specific scaffolding and --base to select the Base UI variant:
# Next.js
pnpm dlx shadcn@latest init -t next --base base
# Vite
pnpm dlx shadcn@latest init -t vite --base base
# TanStack Start
pnpm dlx shadcn@latest init -t start --base base
# React Router
pnpm dlx shadcn@latest init -t react-router --base base
# Laravel
pnpm dlx shadcn@latest init -t laravel --base base
# Astro
pnpm dlx shadcn@latest init -t astro --base base
Additional init flags:
--monorepo— scaffold for monorepo setups--rtl— enable right-to-left support-p, --preset [name]— use a preset config (name, URL, or code)-d, --defaults— use default config without prompts
Install Components
# Individual components
pnpm dlx shadcn@latest add button input form card dialog select
# All components at once
pnpm dlx shadcn@latest add --all
Required Dependencies
{
"dependencies": {
"@base-ui/react": "^1.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"@phosphor-icons/react": "^2.1.7",
"tailwind-merge": "^2.0.0"
}
}
CLI Commands
Beyond init and add, the CLI provides:
# Preview components before installing
pnpm dlx shadcn@latest view button card dialog
# Search registries
pnpm dlx shadcn@latest search @shadcn -q "button"
# Fetch component docs from CLI
pnpm dlx shadcn@latest docs button
# Display project info (framework, CSS vars, installed components)
pnpm dlx shadcn@latest info
# Build registry JSON files
pnpm dlx shadcn@latest build
# Migrations
pnpm dlx shadcn@latest migrate icons # migrate icon library
pnpm dlx shadcn@latest migrate radix # migrate to unified radix-ui package
pnpm dlx shadcn@latest migrate rtl # add RTL support (ml-4 → ms-4, etc.)
CSS Variables & Theming
shadcn/ui uses oklch color format with a background/foreground naming convention. The --primary variable is the background color; --primary-foreground is the text color used on that background.
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.546 0.198 38.228);
--chart-4: oklch(0.596 0.151 343.253);
--chart-5: oklch(0.546 0.158 49.157);
--sidebar-background: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.556 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.698 0.141 24.311);
--chart-4: oklch(0.676 0.172 171.196);
--chart-5: oklch(0.578 0.192 302.85);
}
Available base colors: Neutral, Stone, Zinc, Mauve, Olive, Mist, Taupe.
Adding Custom Colors
Use @theme inline directive (Tailwind v4) to register custom CSS variables as colors:
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
Then use as bg-warning text-warning-foreground in your components.
Core Components
shadcn/ui provides 59 components: Accordion, Alert, Alert Dialog, Aspect Ratio, Avatar, Badge, Breadcrumb, Button, Button Group, Calendar, Card, Carousel, Chart, Checkbox, Collapsible, Combobox, Command, Context Menu, Data Table, Date Picker, Dialog, Direction, Drawer, Dropdown Menu, Empty, Field, Hover Card, Input, Input Group, Input OTP, Item, Kbd, Label, Menubar, Native Select, Navigation Menu, Pagination, Popover, Progress, Radio Group, Resizable, Scroll Area, Select, Separator, Sheet, Sidebar, Skeleton, Slider, Sonner, Spinner, Switch, Table, Tabs, Textarea, Toast, Toggle, Toggle Group, Tooltip, Typography.
All components are available in both Radix UI and Base UI variants, selectable via --base flag during init.
Button
pnpm dlx shadcn@latest add button
import { Button } from "@/components/ui/button";
// Variants: default, destructive, outline, secondary, ghost, link
// Sizes: xs, sm, default, lg, icon, icon-xs, icon-sm, icon-lg
<Button variant="destructive" size="sm">Delete</Button>
Icons in buttons — use the data-icon attribute:
<Button>
<PlusIcon data-icon="inline-start" />
Add Item
</Button>
<Button>
Settings
<GearIcon data-icon="inline-end" />
</Button>
Loading state with Spinner:
<Button disabled>
<Spinner /> Saving...
</Button>
Button as a link — must pass nativeButton={false} when rendering a non-button element:
import Link from "next/link";
import { Button } from "@/components/ui/button";
<Button nativeButton={false} render={<Link href="/signup" />}>
Get Started
</Button>
This also applies to AlertDialogAction, AlertDialogTrigger, DialogTrigger, etc.:
<AlertDialogAction nativeButton={false} render={<Link href="/next-step" />}>
Continue
</AlertDialogAction>
Rounded buttons: use rounded-full class.
ButtonGroup: Group related buttons together:
import { ButtonGroup } from "@/components/ui/button"
<ButtonGroup>
<Button variant="outline">Left</Button>
<Button variant="outline">Center</Button>
<Button variant="outline">Right</Button>
</ButtonGroup>
Input & Label
pnpm dlx shadcn@latest add input label
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" placeholder="Email" />
</div>
Field (Accessible Form Fields)
The Field component provides structured, accessible form markup:
import { Field, FieldLabel, FieldError, FieldDescription } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input {...field} aria-invalid={fieldState.invalid} />
<FieldDescription>We'll never share your email.</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
Forms with Validation
pnpm dlx shadcn@latest add form
"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"
const formSchema = z.object({
username: z.string().min(2, { message: "Username must be at least 2 characters." }),
email: z.string().email({ message: "Please enter a valid email address." }),
})
export function ProfileForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { username: "", email: "" },
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
Validation modes: onChange, onBlur, onSubmit (default), onTouched, all.
Dynamic arrays with useFieldArray:
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
})
{fields.map((field, index) => (
<FormField key={field.id} control={form.control} name={`items.${index}.value`}
render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<Button type="button" onClick={() => remove(index)}>Remove</Button>
</FormItem>
)}
/>
))}
<Button type="button" onClick={() => append({ value: "" })}>Add Item</Button>
Select in form — use onValueChange and defaultValue:
<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>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
Checkbox in form — use checked and onCheckedChange:
<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Email notifications</FormLabel>
</div>
</FormItem>
)}
/>
Switch in form — use checked and onCheckedChange:
<FormField
control={form.control}
name="emailNotifications"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Email Notifications</FormLabel>
<FormDescription>Receive emails about account activity.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
Card
pnpm dlx shadcn@latest add card
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent><p>Card Content</p></CardContent>
<CardFooter><p>Card Footer</p></CardFooter>
</Card>
Dialog
"use client"
import {
Dialog, DialogContent, DialogDescription, DialogHeader,
DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>
Edit Profile
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Make changes to your profile here.</DialogDescription>
</DialogHeader>
{/* form content */}
</DialogContent>
</Dialog>
Features: custom close button via showCloseButton={false}, sticky footer, scrollable content.
AlertDialog (Confirmation Modal)
"use client"
import {
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader,
AlertDialogFooter, AlertDialogTitle, AlertDialogDescription,
AlertDialogAction, AlertDialogCancel,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
<AlertDialog>
<AlertDialogTrigger render={<Button variant="outline" />}>
Delete Item
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Popover
"use client"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
Open Popover
</PopoverTrigger>
<PopoverContent side="top" align="center">
<p className="text-sm">Popover content here.</p>
</PopoverContent>
</Popover>
DropdownMenu
"use client"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
Open Menu
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Log out</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
For submenus, checkboxes, and radio items, use DropdownMenuSub, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem.
Select
pnpm dlx shadcn@latest add select
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
</Select>
Table
pnpm dlx shadcn@latest add table
import {
Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table"
<Table>
<TableCaption>A list of recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">INV001</TableCell>
<TableCell>Paid</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableBody>
</Table>
Charts
Built on Recharts with ChartContainer and ChartConfig for consistent theming. See references/chart.md for full chart examples (bar, line, area, pie).
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltipContent } from "@/components/ui/chart"
const chartConfig = {
desktop: { label: "Desktop", color: "var(--chart-1)" },
} satisfies import("@/components/ui/chart").ChartConfig
<ChartContainer config={chartConfig} className="min-h-[200px] w-full">
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
<ChartTooltip content={<ChartTooltipContent />} />
</BarChart>
</ChartContainer>
Toast (Sonner)
pnpm dlx shadcn@latest add sonner
Add <Toaster /> to your root layout, then use the toast() function:
import { toast } from "sonner"
toast("Event created", {
description: "Friday, March 14, 2026",
action: { label: "Undo", onClick: () => console.log("Undo") },
})
Dark Mode
Next.js (using next-themes)
pnpm add next-themes
Create components/theme-provider.tsx:
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
Wrap in app/layout.tsx:
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
Vite
Create a ThemeProvider using React context + localStorage + prefers-color-scheme media query. Supports "light", "dark", "system" themes. Persist choice in localStorage.
Customizing Components
Since you own the code, customize directly. Components use cva (class-variance-authority) for variant styling and cn() for class merging. Example button.tsx:
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils/ui-utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors outline-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20",
outline: "border border-input bg-background hover:bg-muted",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-muted hover:text-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 gap-1.5 px-2.5",
xs: "h-7 gap-1 px-2 text-xs",
sm: "h-8 gap-1 px-2.5 text-sm",
lg: "h-10 gap-1.5 px-2.5",
icon: "size-9",
"icon-xs": "size-7",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({ className, variant = "default", size = "default", ...props }:
ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}
export { Button, buttonVariants }
Next.js Integration
- Add
"use client"to interactive components - Add
<Toaster />to root layout for toast notifications - Use
cn()from@/lib/utilsfor class merging - Wrap interactive shadcn components in Client Components when used in Server Components
- Configure
rsc: trueincomponents.jsonfor React Server Components support
RTL Support
Enable RTL during initialization or migrate an existing project:
# New project
pnpm dlx shadcn@latest init --rtl
# Existing project
pnpm dlx shadcn@latest migrate rtl
The migration converts physical CSS properties to logical ones (ml-4 becomes ms-4, pr-2 becomes pe-2, etc.) so layouts work correctly in both LTR and RTL contexts.
Key Differences from Standard shadcn/ui
| Standard shadcn/ui | This Project (Base UI variant) |
|---|---|
Multiple @radix-ui/* packages |
Single @base-ui/react package |
asChild prop |
render prop |
data-[state=open] |
data-open |
data-[state=active] (tabs) |
data-[active] (tabs) |
| Direct Portal usage | Portal > Positioner > Popup (handled by wrappers) |
Slot from @radix-ui/react-slot |
render={<Component />} prop |
No data-slot convention |
data-slot attributes on all wrappers |
lucide-react icons |
@phosphor-icons/react icons |
| HSL color format (legacy) | oklch color format |
For detailed Base UI primitive API and patterns, consult the kao-base-ui skill.
components.json
The components.json file configures paths, aliases, and settings:
{
"style": "new-york",
"rsc": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
References
For detailed examples and API docs, see:
references/chart.md— Chart component examplesreferences/ui-reference.md— Full component API referencereferences/reference.md— Additional component patternsreferences/learn.md— Learning guide for newcomers