shadcn/ui component library conventions -- use when project has components.json or @/components/ui/ directory. Covers component usage, customization, theming, composition patterns, and common pitfalls.
From beenpx claudepluginhub bee-coded/bee-dev --plugin beeThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Sorts ECC skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using repo evidence like file extensions and configs. Creates trimmed install plan for project-specific needs.
These standards apply when the project uses shadcn/ui. Detection: check for components.json at project root OR @/components/ui/ directory with shadcn component files. If neither exists, this skill does not apply.
Also read the active stack skill (react, vue, nextjs, etc.) for framework-specific conventions. This skill covers shadcn-specific patterns only.
shadcn/ui is NOT a dependency — it's a code distribution platform. Components are copied into your project and become YOUR code. This means:
@/components/ui/ (or wherever aliases.ui points in components.json)npx shadcn@latest add <component> overwrites your file)node_modules shadcn package — only the underlying primitives (Radix UI, etc.)components.json ← shadcn configuration (aliases, style, base color)
src/
components/
ui/ ← shadcn primitives (DO NOT put custom components here)
button.tsx
dialog.tsx
input.tsx
...
custom/ ← your composed components using shadcn primitives
user-form.tsx
data-table-toolbar.tsx
...
lib/
utils.ts ← cn() utility function
cn() UtilityAll class merging uses cn() from @/lib/utils. This wraps clsx + tailwind-merge for conflict-free class composition:
import { cn } from "@/lib/utils"
// cn() merges classes, resolving Tailwind conflicts correctly
<div className={cn("px-4 py-2", variant === "ghost" && "bg-transparent", className)} />
NEVER use raw string concatenation for classes. ALWAYS use cn().
Always import from @/components/ui/:
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
shadcn components are composition-first. Complex UI is built by nesting primitives:
// Pattern: composed form field with label, input, and error
function FormField({ label, error, ...inputProps }: FormFieldProps) {
return (
<div className="space-y-2">
<Label htmlFor={inputProps.id}>{label}</Label>
<Input {...inputProps} className={cn(error && "border-destructive")} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
Most shadcn components use variant and size props powered by cva (class-variance-authority):
<Button variant="default" /> // primary action
<Button variant="secondary" /> // secondary action
<Button variant="destructive" /> // dangerous action
<Button variant="outline" /> // bordered, no fill
<Button variant="ghost" /> // no border, no fill
<Button variant="link" /> // text-only, underlined
<Button size="default" /> // standard
<Button size="sm" /> // compact
<Button size="lg" /> // prominent
<Button size="icon" /> // square, for icon-only buttons
When you need custom variants, extend the existing component file in ui/:
// In @/components/ui/button.tsx — add a new variant
const buttonVariants = cva("...", {
variants: {
variant: {
default: "...",
// ... existing variants
success: "bg-green-600 text-white hover:bg-green-700", // ← added
},
},
});
DO NOT create wrapper components just to add a className. Extend the variant system instead.
asChild PatternMany shadcn components support asChild prop (from Radix UI Slot). This renders the child element instead of the default element, merging props:
// Render a link that looks like a button
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
// Render a custom trigger for a dialog
<DialogTrigger asChild>
<Button variant="outline">Open Settings</Button>
</DialogTrigger>
shadcn uses CSS variables for theming. All colors reference semantic tokens:
/* Semantic tokens — defined in globals.css */
--background /* page background */
--foreground /* default text */
--primary /* primary actions, buttons */
--primary-foreground /* text on primary background */
--secondary /* secondary elements */
--muted /* subtle backgrounds */
--muted-foreground /* subtle text (placeholders, hints) */
--accent /* hover states, highlights */
--destructive /* error, danger, delete */
--border /* borders, dividers */
--input /* input borders */
--ring /* focus ring */
ALWAYS use semantic color classes, NEVER raw Tailwind colors:
// ✅ Correct — uses theme tokens
<p className="text-muted-foreground">Helper text</p>
<div className="bg-card border border-border rounded-lg">...</div>
<span className="text-destructive">Error message</span>
// ❌ Wrong — hardcoded colors bypass theming
<p className="text-gray-500">Helper text</p>
<div className="bg-white border border-gray-200 rounded-lg">...</div>
<span className="text-red-500">Error message</span>
shadcn supports dark mode via .dark class on <html> or <body>. When implementing dark mode:
:root and .dark variants@custom-variant dark (&:is(.dark *)) in Tailwind v4To customize the theme, modify CSS variables in globals.css. Use oklch color space (shadcn default since v2):
:root {
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
}
Use the shadcn themes tool (ui.shadcn.com/themes) to generate color palettes, then paste into your CSS.
<Dialog>
<DialogTrigger asChild>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Make changes to your profile here.</DialogDescription>
</DialogHeader>
{/* form content */}
<DialogFooter>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Use Dialog for focused actions. Use Sheet for side panels with more content.
shadcn provides a Form component that integrates with React Hook Form:
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "" },
});
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
</form>
</Form>
shadcn's DataTable pattern uses @tanstack/react-table:
import { ColumnDef } from "@tanstack/react-table"
import { DataTable } from "@/components/ui/data-table"
const columns: ColumnDef<Payment>[] = [
{ accessorKey: "status", header: "Status" },
{ accessorKey: "email", header: "Email" },
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
return <div className="text-right font-medium">{formatted}</div>;
},
},
];
<DataTable columns={columns} data={payments} />
shadcn provides a Sidebar component with its own CSS variables (--sidebar-*):
import { SidebarProvider, Sidebar, SidebarContent, SidebarGroup, SidebarMenuItem } from "@/components/ui/sidebar"
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarMenuItem>Dashboard</SidebarMenuItem>
<SidebarMenuItem>Orders</SidebarMenuItem>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main>{children}</main>
</SidebarProvider>
shadcn wraps Recharts with themed components:
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
const chartConfig = {
desktop: { label: "Desktop", color: "var(--chart-1)" },
mobile: { label: "Mobile", color: "var(--chart-2)" },
} satisfies ChartConfig;
<ChartContainer config={chartConfig}>
<BarChart data={data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
</BarChart>
</ChartContainer>
npx shadcn@latest add <component> to copy components into your project.@/components/ui/ — that directory is for shadcn primitives only. Custom components go in @/components/custom/ or feature directories.text-gray-500, bg-blue-600) when semantic tokens exist (text-muted-foreground, bg-primary). Hardcoded colors break theming.cn() from @/lib/utils.ui/ file instead.ui/ files AND forget to check if npx shadcn add will overwrite your changes — document custom modifications.onClick on DialogTrigger or SheetTrigger — use asChild and put the handler on the child element.asChild when wrapping a custom element inside a shadcn trigger/slot component.var(--chart-N) CSS variables that auto-adapt to dark mode.cn() for all class merging. Every dynamic className uses cn(). No exceptions.text-foreground, bg-card, border-border), never raw Tailwind palette colors.components.json configuration. Project has a valid components.json with correct aliases, style, and Tailwind CSS path.ui/ from custom components. shadcn primitives in ui/, composed components elsewhere.asChild on triggers. When using custom elements as triggers (Dialog, Sheet, Tooltip, Popover), always use asChild.npx shadcn@latest add button dialog input — don't manually copy files.button.tsx, don't create SuccessButton.tsx.Dialog + Form + Input + Select + Button. Don't build monolithic components.Sheet for mobile navigation. On small screens, swap Sidebar for Sheet with the same content.Sonner for toasts. shadcn integrates with sonner — don't build custom toast systems.ChartConfig + CSS variables for consistent chart styling across light/dark modes.cn() import. Forgetting to import cn from @/lib/utils when adding dynamic classes.text-gray-500 instead of text-muted-foreground — works in light mode, breaks in dark mode.asChild on triggers. DialogTrigger without asChild renders an extra button element, causing nested <button> HTML violations.ui/ files. Running npx shadcn add button overwrites your custom variants. Use --diff flag to preview changes first.@radix-ui/react-dialog directly instead of @/components/ui/dialog. Always use the shadcn wrapper.--sidebar-* variables. If you override --background globally, sidebar may look wrong.<FormMessage /> inside <FormField> — errors exist but aren't displayed.ui/. Putting everything in @/components/ui/ — it should only contain shadcn primitives, not your business components.MyButton, MyInput, MyDialog wrappers that just pass props through — extend variants instead.components.json aliases. Importing from hardcoded paths instead of using the configured aliases.fill="#2563eb" instead of fill="var(--color-desktop)" — breaks theming and dark mode.When looking up shadcn/ui documentation, use these Context7 library identifiers:
/websites/ui_shadcn — components, theming, configuration, patternsradix-ui/primitives — underlying primitives, accessibility, composition APItanstack/table — data table patterns, sorting, filtering, paginationrecharts/recharts — chart components used by shadcn chartsAlways check Context7 for component APIs — shadcn updates frequently and component props may change between versions.