Claude Code with shadcn/ui: Setup, Theming, Composition
Why shadcn/ui needs different rules than other component libraries
shadcn/ui is not an npm package. It is a CLI that copies components into your codebase, leaves them there for you to own, and never re-installs them. That single architectural decision changes how Claude Code should work with the library compared to MUI, Chakra, or Mantine.
With a packaged library, Claude reads the package types and trusts the underlying implementation. With shadcn/ui, every component file lives in your repository, and Claude has full read and write access to the primitives. The upside is total control. The downside is that without explicit rules, Claude treats the shadcn components like ordinary application code: it edits them inline, forks them when it should wrap, and invents Tailwind tokens that do not exist in your components.json registry.
The fix is a CLAUDE.md that codifies four things: which files the shadcn CLI owns, how theming maps to CSS variables, when to wrap a primitive versus fork it, and how Form components must bind to React Hook Form. After that, Claude generates shadcn work that survives the next npx shadcn@latest add without merge conflicts. If you are setting up Claude Code from scratch, the Claude Code setup guide covers installation and authentication first.
Initial setup, components.json, and the CLAUDE.md template
The setup is one command in a Next.js or Vite project that already has Tailwind configured:
npx shadcn@latest init
The CLI asks four questions: which style (default or new-york), which base colour (slate, gray, zinc, neutral, stone), whether to use CSS variables, and where to write components.json. Pick CSS variables every time. The alternative is hardcoded Tailwind classes baked into each component, which removes the entire theming layer and forces Claude to chase colour values across dozens of files when you want to rebrand.
The resulting components.json is the registry contract. Every shadcn CLI invocation reads it. Every Claude session should read it. A typical file:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
Two fields matter most for Claude Code. aliases.ui tells the CLI where primitives land, which means every new component goes to src/components/ui/. tailwind.cssVariables: true tells the CLI to generate components that read from CSS variables rather than literal Tailwind colour utilities. Claude must respect both rules or the next add command will fight your existing files.
Add a single shadcn component to validate the wiring:
npx shadcn@latest add button
The CLI writes src/components/ui/button.tsx, updates globals.css with the variable map if it is missing, and exits. The variants live in a CVA config that references variables like bg-primary and text-primary-foreground. Those tokens resolve through globals.css, not through Tailwind defaults. That is the contract Claude needs to understand.
A good shadcn CLAUDE.md answers five questions: which files the CLI owns, where tokens live, when to wrap versus fork, what Form bindings are required, and what Radix patterns must be preserved.
# shadcn/ui project rules
## Component ownership
- src/components/ui/* are owned by the shadcn CLI
- To add a new primitive, run `npx shadcn@latest add <component>`, do not handwrite it
- To customise a primitive, edit the file in src/components/ui/*, the CLI does not regenerate existing files unless --overwrite is passed
- Do not delete src/components/ui/* files, the registry expects them present
## Theming
- All colour tokens are CSS variables in src/app/globals.css under :root and .dark
- New colours must be added to both :root and .dark, never to one only
- Component variants reference tokens via Tailwind utilities (bg-primary, text-muted-foreground)
- Do not invent new token names, the allowed set is in globals.css
- Do not use hex values or arbitrary Tailwind colours in src/components/ui/*
## Composition
- Application components live in src/components/{feature}/
- Application components wrap shadcn primitives, they do not import Radix directly
- Fork a primitive only when the shadcn version cannot express the required variant via className or asChild
- Wrappers extend, they do not replace the underlying props
## Forms
- All forms use react-hook-form with zodResolver
- Field components use the shadcn Form, FormField, FormItem, FormControl primitives
- Validation lives in a zod schema, never inline in onSubmit
- Error messages render through FormMessage, not custom error UI
## Hard rules
- Do not regenerate files in src/components/ui/* without confirming the diff first
- Do not bypass FormField when using shadcn Form
- Do not edit Radix primitive props through className overrides, use the documented prop or asChild
- TypeScript strict mode is on, accept the generated types from CVA
This file loads at the start of every session. The ownership rule alone prevents the most common shadcn failure mode: Claude rewriting a primitive file in a style that drifts from the registry, then the next CLI update conflicting on every line.
Theming, CSS variables, and dark mode
When the CLI initialises a project with CSS variables enabled, it writes a two-block variable map to your global stylesheet. The exact tokens depend on the style and base colour, but the structure is fixed:
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
Two rules govern this file. First, every token defined in :root must have a counterpart in .dark, or the dark mode flip produces visual breakage. Second, tokens use raw HSL channel values without the hsl() wrapper, because the wrapper is applied inside the Tailwind config via hsl(var(--primary)). Claude regularly gets the second rule wrong on first attempt, so codify it: variable values are raw channels ("0 0% 100%"), and the Tailwind config wraps them.
To add a brand colour, append two paired tokens:
:root {
--brand: 220 90% 56%;
--brand-foreground: 0 0% 98%;
}
.dark {
--brand: 220 85% 64%;
--brand-foreground: 240 10% 3.9%;
}
Then extend the Tailwind config to surface the utility:
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
theme: {
extend: {
colors: {
brand: {
DEFAULT: "hsl(var(--brand))",
foreground: "hsl(var(--brand-foreground))",
},
},
},
},
};
export default config;
After that, bg-brand and text-brand-foreground work across both modes. Without the CLAUDE.md rule, Claude tends to write bg-[hsl(220,90%,56%)] inline, which bypasses the token and breaks dark mode entirely. For the broader Tailwind setup that pairs with this, the Claude Code with Tailwind CSS guide covers the v4 @theme approach and token discipline.
shadcn/ui assumes class-based dark mode. The .dark class on a root element flips every variable in one cascade. The next-themes library is the standard pairing:
// src/app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
</body>
</html>
);
}
The attribute="class" prop tells next-themes to write class="dark" rather than data-theme="dark". shadcn primitives expect the class form. Without suppressHydrationWarning on the <html> element, the initial render mismatches the client and Next.js logs a hydration warning. The CLAUDE.md rule: next-themes with attribute="class" only, no prefers-color-scheme media queries in component CSS, suppressHydrationWarning required on the html element, and the mode toggle reads from useTheme() rather than local state. With that in place, Claude builds a theme toggle that calls setTheme("dark") instead of reinventing it with localStorage and a useEffect.
Composition rules and asChild
This pair of rules prevents the largest category of shadcn drift. Claude's instinct on a request like "make the button rounded" is to open src/components/ui/button.tsx and edit it. That works once. It fails the next time the CLI ships a button update and your changes block the merge.
The decision tree:
## When to wrap a shadcn primitive
Wrap (preferred) if:
- You need a styled variant of an existing primitive
- The change is additive (new prop, new variant in CVA)
- Multiple call sites need the same customisation
Fork (last resort) if:
- The primitive cannot express the requirement through className, variant, or asChild
- You need to change the underlying Radix anatomy
- The change would conflict with future shadcn updates
Never edit ui/* inline for:
- One-off styling, use className on the call site instead
- Padding, margin, sizing, use Tailwind utilities at the call site
- Colour overrides, use the CSS variable system, not hardcoded values
The wrap pattern in practice:
// src/components/marketing/cta-button.tsx
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface CtaButtonProps extends ButtonProps {
emphasis?: "default" | "high";
}
export function CtaButton({ emphasis = "default", className, ...props }: CtaButtonProps) {
return (
<Button
{...props}
className={cn(
"rounded-full font-semibold tracking-tight",
emphasis === "high" && "bg-brand text-brand-foreground hover:bg-brand/90",
className
)}
/>
);
}
The wrapper extends ButtonProps, so every shadcn variant remains available. The className prop merges through cn(), which uses tailwind-merge under the hood and resolves conflicts in favour of the call-site override. The underlying Button file is untouched. The next CLI update applies cleanly.
The Radix asChild pattern is the second composition tool. When you need the shadcn styling on a different element, do not duplicate the component:
import { Button } from "@/components/ui/button";
import Link from "next/link";
<Button asChild>
<Link href="/pricing">View pricing</Link>
</Button>
asChild is a Radix slot primitive that delegates rendering to the child while keeping the styles, refs, and event handlers. Claude often misses this and builds a LinkButton component that copies all the styling. The CLAUDE.md rule is short: use asChild when you need shadcn styling on a different element, and never duplicate styling to support a different element. The child must accept className and forward it, which Next.js Link does natively. For deeper React composition principles that apply across all component work, the Claude Code with React guide covers the patterns that pair with this.
Forms, Zod schemas, and accessibility
shadcn ships a Form primitive that is a thin wrapper around React Hook Form. It provides FormField, FormItem, FormLabel, FormControl, FormDescription, and FormMessage. These are not optional. They wire the controlled input back to the form state and surface validation errors through the same component. Skipping them breaks the integration.
The full pattern, end to end:
// src/lib/schemas/signup.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/[0-9]/, "Password must contain a number"),
marketingOptIn: z.boolean().default(false),
});
export type SignupValues = z.infer<typeof signupSchema>;
The schema is the contract. The form imports it, the API route imports it, the database insert validates against it. One source of truth, three call sites. For the schema patterns that apply across an application, see using Claude Code with Zod.
The form component:
// src/components/auth/signup-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupValues } from "@/lib/schemas/signup";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
export function SignupForm() {
const form = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "", marketingOptIn: false },
});
async function onSubmit(values: SignupValues) {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
<FormDescription>
At least 8 characters with one uppercase and one number.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingOptIn"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="font-normal">
Send me product updates
</FormLabel>
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Create account
</Button>
</form>
</Form>
);
}
Three details Claude regularly gets wrong without a CLAUDE.md rule. First, the resolver: without zodResolver, the schema is decorative and validation never runs. Second, defaultValues: without them, React Hook Form treats every field as uncontrolled initially, which produces a console warning when the user starts typing. Third, FormMessage: without it, the validation error is captured by RHF but never rendered, so users see nothing when validation fails. The form contract that codifies this: every form uses useForm with zodResolver and defaultValues for every field, validation lives only in the schema (never in onSubmit), and the schema lives in src/lib/schemas/ so the form and the API route import the same source.
Radix primitives handle most accessibility automatically. Dialog handles focus trapping, escape-to-close, and aria-labelledby. Popover handles outside-click dismissal and aria-haspopup. Combobox handles arrow-key navigation. Three caveats remain. Labels are your job: every FormLabel needs meaningful copy and every icon-only button needs aria-label. Dialog titles are required: every Radix Dialog needs a DialogTitle, wrapped in VisuallyHidden if it should not be visible (omitting it triggers a Radix warning and breaks screen-reader announcements). Focus-visible matters: the --ring token drives the focus ring colour, so customise the ring offset at the call site rather than stripping focus-visible:ring-2 from the primitive.
Hard rules and final wiring
The patterns above compress into a short hard-rules section that lives at the top of CLAUDE.md. Every session reads it before touching shadcn files.
## shadcn hard rules
- src/components/ui/* are CLI-owned, prefer wrapping over editing
- No hex values or hsl() literals in components, use CSS variables only
- No inventing new theme tokens, the allowed set is in globals.css
- No bypassing FormField in shadcn Form contexts
- No prefers-color-scheme media queries, class-based dark mode only
- No icon-only buttons without aria-label
- No Dialog without DialogTitle (use VisuallyHidden if not visible)
- Use asChild before duplicating a primitive for a different element
- New primitives via `npx shadcn@latest add <name>`, never handwritten
These rules cost nothing to write and prevent the four highest-frequency shadcn regressions: token drift, form-validation no-ops, accessibility loss, and merge conflicts on the next CLI update. The CLAUDE.md explained guide covers the broader patterns for writing project rules that Claude follows consistently, and the Claude Code best practices guide covers the session structure and review patterns that pair with this configuration.
Want a shadcn/ui CLAUDE.md template that works from day one? Claudify ships a configured shadcn template, the Form pattern bindings, and the composition rules above as part of the starter kit. One command: npx create-claudify.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify