Using shadcn/ui with Claude Code: A Practical React Guide
Use Claude Code with shadcn/ui for setup, Button/Card/Form/Dialog/Table, tokens, drift control, and Playwright checks.
shadcn/ui is not a traditional component library that stays hidden inside node_modules. Its CLI copies component source into your project, usually under src/components/ui, so your team owns the code. That is exactly why it pairs well with Claude Code: Claude can inspect the generated files, update variants, wire forms, and add tests without guessing what the design system looks like.
The downside is the same ownership. If you ask Claude Code to “make a dashboard” without constraints, it may create duplicate buttons, paste outdated Tailwind configuration, or remove important Dialog accessibility pieces. This guide shows a safer workflow for a Vite + React + TypeScript app: install shadcn/ui, add Button/Card/Form/Dialog/Table, define design tokens, prevent copy-paste drift, and verify the result with Playwright screenshots.
If you are new to Claude Code itself, start with the Claude Code getting started guide. This article assumes you can run Claude Code in a project and review its diffs.
Start With Official Sources
Before touching files, give Claude Code the current sources. The shadcn/ui Vite docs use shadcn@latest, Tailwind CSS v4 documents CSS-first theme variables through @theme, Radix UI documents the accessibility behavior behind Dialog, and Playwright documents screenshot comparisons with toHaveScreenshot().
- shadcn/ui Vite installation
- shadcn/ui React Hook Form guide
- Tailwind CSS theme variables
- Radix UI Dialog docs
- Claude Code quickstart
- Playwright visual comparisons
flowchart LR
A["Inspect the app with Claude Code"] --> B["Run shadcn/ui init"]
B --> C["Add UI primitives"]
C --> D["Centralize design tokens"]
D --> E["Build app-level wrappers"]
E --> F["Lock visuals with Playwright"]
Install and Initialize shadcn/ui
This example uses Vite because it keeps the React surface small. The same principles apply to Next.js, but beginners should avoid mixing Server Components, routing, and UI-system setup on day one.
pnpm create vite@latest shadcn-claude-demo -- --template react-ts
cd shadcn-claude-demo
pnpm install
pnpm add tailwindcss @tailwindcss/vite
pnpm add -D @types/node
Configure Vite so Tailwind runs and the @ alias resolves.
import path from "node:path"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})
Add the same alias to TypeScript.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
Ask Claude Code to inspect first, then run commands after you approve the plan.
I want to add shadcn/ui to this Vite + React + TypeScript project.
First inspect package.json, vite.config.ts, tsconfig*.json, and src/index.css.
Report the missing setup only. After I approve, run shadcn@latest init
and add the components we need.
Then add the UI pieces and form dependencies.
pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button card field input label dialog table
pnpm add react-hook-form zod @hookform/resolvers
For forms, the current shadcn/ui guide uses React Hook Form, Zod, and the Field component. Do not blindly paste older snippets that assume a different form helper.
Build the First Card and Button
Start with one reusable app component. Keep shadcn-generated files in src/components/ui, then put product-specific UI in src/components/app.
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
type ProjectSummaryCardProps = {
name: string
openIssues: number
onCreateTask: () => void
}
export function ProjectSummaryCard({
name,
openIssues,
onCreateTask,
}: ProjectSummaryCardProps) {
return (
<Card className="max-w-md">
<CardHeader>
<CardTitle>{name}</CardTitle>
<CardDescription>
Review open UI issues before starting the next task.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">{openIssues}</p>
<p className="text-muted-foreground text-sm">open issues</p>
</CardContent>
<CardFooter>
<Button onClick={onCreateTask}>Add task</Button>
</CardFooter>
</Card>
)
}
Use a narrow review prompt:
Review only src/components/app/ProjectSummaryCard.tsx.
Check prop types, shadcn/ui imports, Tailwind readability, and accessibility.
Suggest changes in other files only if they are required, and explain why.
Implement a Real Form
A form is not just a pretty input. Production forms need validation, error text, disabled submit states, autocomplete, and accessible error signaling.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const contactSchema = z.object({
email: z.string().email("Enter a valid email address"),
topic: z.string().min(4, "Use at least 4 characters"),
})
type ContactFormValues = z.infer<typeof contactSchema>
export function ContactForm() {
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
email: "",
topic: "",
},
})
function onSubmit(values: ContactFormValues) {
console.log("submit", values)
}
return (
<form className="max-w-md space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
autoComplete="email"
/>
<FieldDescription>We will use this address to reply.</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="topic"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Topic</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="Example: Improve admin UI"
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</FieldGroup>
<Button type="submit" disabled={form.formState.isSubmitting}>
Send
</Button>
</form>
)
}
For small forms, keeping the schema and component together is fine. When forms grow, ask Claude Code to split schema.ts, ContactForm.tsx, and server actions deliberately instead of letting it invent structure mid-edit.
Combine Dialog and Table Safely
Radix UI Dialog handles modal behavior, focus trapping, Escape close, and screen-reader announcements through Title and Description. shadcn/ui styles those parts, but you still need to keep them in the markup.
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type Customer = {
id: string
name: string
plan: "Free" | "Pro" | "Team"
}
const customers: Customer[] = [
{ id: "cus_001", name: "Aoi Tanaka", plan: "Pro" },
{ id: "cus_002", name: "Mika Sato", plan: "Team" },
]
export function CustomerTable() {
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers.map((customer) => (
<TableRow key={customer.id}>
<TableCell className="font-medium">{customer.name}</TableCell>
<TableCell>{customer.plan}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCustomer(customer)}
>
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog
open={selectedCustomer !== null}
onOpenChange={(open) => {
if (!open) setSelectedCustomer(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit customer</DialogTitle>
<DialogDescription>
Review the contract plan for {selectedCustomer?.name}.
</DialogDescription>
</DialogHeader>
<p className="text-sm">
Current plan: <strong>{selectedCustomer?.plan}</strong>
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setSelectedCustomer(null)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
Keep data loading outside this component at first. Page code can fetch customers and pass customers plus onEdit, while the table stays easy for Claude Code and humans to review.
Manage Design Tokens
Design tokens are named values for color, spacing, radius, shadow, typography, and similar UI decisions. Tailwind v4 lets @theme create utilities from theme variables. shadcn/ui components also rely on CSS variables like --background, --primary, and --border.
@import "tailwindcss";
@theme {
--font-sans: Inter, system-ui, sans-serif;
--radius-card: 0.75rem;
--color-brand-500: oklch(0.62 0.18 250);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
If an older project still uses tailwind.config.ts, ask Claude Code for two plans: a Tailwind v4 migration plan and a minimal v3-compatible patch. Mixing both in one edit is a common source of broken classes.
Prevent Copy-Paste Drift
shadcn/ui gives you source code. That freedom is useful, but it also creates drift if every screen gets its own “almost the same” Button.
| Rule | Why it matters |
|---|---|
Keep src/components/ui for shadcn-derived primitives | Easier to compare with generated code |
Put product-specific UI in src/components/app | Keeps business logic away from the base layer |
Do not casually change components.json aliases | Prevents import churn |
| Give Claude Code exact target files | Reduces duplicate components |
The existing src/components/ui files come from shadcn/ui.
Do not create a new Button or Card.
Use existing imports and put product-specific UI in src/components/app.
After editing, run rg "function .*Button|export .*Button" src/components
and report whether duplicates were introduced.
Run local checks before you accept the change:
git diff -- src/components/ui
git diff -- src/components/app
pnpm lint
pnpm build
For more workflow guardrails, see Claude Code productivity tips.
Add Playwright Visual Checks
Playwright visual comparisons create a baseline screenshot, then compare later runs against it. Use the same operating system, browser, fonts, and viewport in CI whenever possible.
pnpm create playwright
pnpm playwright install
pnpm dev
pnpm playwright test
import { expect, test } from "@playwright/test"
test("customer table dialog visual state", async ({ page }) => {
await page.goto("/customers")
await page.getByRole("button", { name: "Edit" }).first().click()
await expect(page).toHaveScreenshot("customers-dialog.png", {
maxDiffPixels: 120,
})
})
Update screenshots only when the visual change is intentional.
pnpm playwright test --update-snapshots
If a screenshot fails, give Claude Code the narrow visual problem instead of asking for a general cleanup.
The Playwright customers-dialog.png check failed.
The footer buttons are too close together on mobile.
Read only src/components/app/CustomerTable.tsx and related CSS.
Fix spacing, but do not remove DialogTitle or DialogDescription.
Three Practical Use Cases
First, use this workflow for a new admin dashboard. Button, Card, Table, and Dialog cover list, detail, edit, and confirmation flows. Ask Claude Code to build static UI first, then connect real APIs.
Second, use it to standardize an existing product. Replace one old search form, settings dialog, or empty-state card at a time. Avoid a risky full redesign unless you already have strong visual tests.
Third, use it for paid prototypes and client demos. Tell Claude Code that mock data is allowed, but component boundaries must be production-ready. That keeps the prototype close to code you can actually ship.
Accessibility work is another good fit. Pair this guide with Claude Code accessibility practices when reviewing labels, keyboard behavior, and dialog structure.
Common Pitfalls
The biggest mistake is pasting outdated Tailwind snippets into a Tailwind v4 project. Check the installed version and build pipeline before editing configuration.
The second mistake is putting product logic into components/ui. A payment-plan variant inside the base Button will eventually affect unrelated pages. Wrap it at the app layer instead.
Dialog mistakes are subtle. Removing DialogTitle because the title is visually inconvenient can harm screen-reader announcements. If the title should be visually hidden, hide it deliberately instead of deleting it.
Form mistakes usually appear later. Native required attributes are useful, but they are not enough for translated error messages, async validation, and server errors. Decide where schema and error rendering live from the start.
Playwright mistakes are environmental. Screenshots differ across OS, browser, fonts, and viewport. Generate baselines in the same environment that will run the checks.
Prompt Template and Result
Goal: Add shadcn/ui to a Vite + React + TypeScript admin screen.
Use Button, Card, Field-based Form, Dialog, and Table.
Constraints:
- Follow current official docs
- Keep src/components/ui for shadcn/ui primitives
- Put app-specific components in src/components/app
- Use React Hook Form + Zod + Field for forms
- Keep DialogTitle and DialogDescription
- Explain any Tailwind v4 @theme and CSS variable changes
- Add one Playwright toHaveScreenshot test
Process:
1. Inspect existing setup
2. Propose the change plan
3. Implement after approval
4. Run pnpm build and pnpm playwright test
5. Summarize changed files and risks
The tested result: in a minimal Vite app, this flow reproduced the shadcn/ui component install, a token update through @theme, a field-based form, a Dialog opened from a Table row, and a Playwright screenshot check. The only part that needed a second Claude Code pass was form error rendering; explicitly requiring aria-invalid and FieldError fixed it cleanly.
For teams, put the prompt template into CLAUDE.md and treat it as a UI implementation contract. ClaudeCodeLab also packages these review rules and Claude Code prompts into paid templates for teams that want repeatable UI delivery without rewriting the same guidance every sprint.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.