shadcn-ui
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill shadcn-ui
Agent 安装分布
Skill 文档
shadcn/ui – Component Library
progressive_disclosure: entry_point: summary, when_to_use, quick_start estimated_tokens: entry: 85 full: 4800
Summary
shadcn/ui is a collection of re-usable React components built with Radix UI primitives and styled with Tailwind CSS. Unlike traditional component libraries, shadcn/ui components are copied directly into your project, giving you full ownership and customization control. Components are accessible, customizable, and open source.
Core Philosophy: Copy-paste components, not npm packages. You own the code.
When to Use
Use shadcn/ui when:
- Building React applications with Tailwind CSS
- Need accessible, production-ready UI components
- Want full control over component code and styling
- Prefer composition over configuration
- Building with Next.js, Vite, Remix, or Astro
- Need dark mode support out of the box
- Want TypeScript-first components
Don’t use when:
- Not using Tailwind CSS (core styling dependency)
- Need legacy browser support (uses modern CSS features)
- Prefer packaged npm libraries over code ownership
- Building non-React frameworks (Vue, Svelte, Angular)
Quick Start
Installation
# Initialize shadcn/ui in your project
npx shadcn-ui@latest init
# Follow interactive prompts:
# - TypeScript? (yes/no)
# - Style: Default/New York
# - Base color: Slate/Gray/Zinc/Neutral/Stone
# - CSS variables: (yes/no)
# - React Server Components: (yes/no)
# - components.json location
# - Tailwind config location
# - CSS file location
# - Import alias (@/components)
Add Your First Component
# Add individual components
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add dialog
# Add multiple components at once
npx shadcn-ui@latest add button card dialog form input
Basic Usage
import { Button } from "@/components/ui/button"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
export default function Example() {
return (
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
</CardHeader>
<CardContent>
<Button>Click me</Button>
</CardContent>
</Card>
)
}
Architecture
Copy-Paste Philosophy
Key Difference from Traditional Libraries:
- Traditional:
npm install component-libraryâ locked to package versions - shadcn/ui: Components copied to
components/ui/â you own the code
Benefits:
- Full customization control
- No breaking changes from package updates
- Easy to modify for specific needs
- Transparent implementation
- Tree-shakeable by default
Component Structure
src/
âââ components/
â âââ ui/
â âââ button.tsx # Component implementation
â âââ card.tsx # Owns its code
â âââ dialog.tsx # Modifiable
â âââ ...
âââ lib/
â âââ utils.ts # cn() helper for class merging
âââ app/
âââ globals.css # Tailwind directives + CSS variables
Technology Stack
Core Dependencies:
- Radix UI: Accessible component primitives (headless UI)
- Tailwind CSS: Utility-first styling
- TypeScript: Type safety
- class-variance-authority (CVA): Variant management
- clsx: Class name concatenation
- tailwind-merge: Conflict-free class merging
Radix UI Integration:
// shadcn/ui components wrap Radix primitives
import * as DialogPrimitive from "@radix-ui/react-dialog"
// Add styling and variants
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogContent = React.forwardRef<...>(
({ className, children, ...props }, ref) => (
<DialogPrimitive.Content
ref={ref}
className={cn("fixed ...", className)}
{...props}
/>
)
)
Configuration
components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
Key Options:
style: “default” or “new-york” (design variants)rsc: React Server Components supportcssVariables: Use CSS variables for themingprefix: Tailwind class prefix (optional)
Tailwind Configuration
// tailwind.config.ts
import type { Config } from "tailwindcss"
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config
export default config
CSS Variables (globals.css)
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
Component Catalog
Button
import { Button } from "@/components/ui/button"
// Variants
<Button variant="default">Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
// Sizes
<Button size="default">Default</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
<Button size="icon"><Icon /></Button>
// States
<Button disabled>Disabled</Button>
<Button asChild>
<Link href="/about">As Link</Link>
</Button>
Implementation Pattern (CVA):
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
Card
import {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card"
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card description goes here</CardDescription>
</CardHeader>
<CardContent>
<p>Card content</p>
</CardContent>
<CardFooter>
<Button>Action</Button>
</CardFooter>
</Card>
Dialog (Modal)
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Form (with react-hook-form + zod)
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
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.",
}),
})
function ProfileForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
email: "",
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="user@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
Table
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>INV001</TableCell>
<TableCell>Paid</TableCell>
<TableCell>Credit Card</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableBody>
</Table>
Additional Components
Available via CLI:
accordion– Collapsible content sectionsalert– Contextual feedback messagesalert-dialog– Interrupting modal dialogsavatar– User profile imagesbadge– Status indicatorscalendar– Date pickercheckbox– Binary inputcommand– Command palette (âK menu)context-menu– Right-click menusdropdown-menu– Dropdown menushover-card– Hover tooltipsinput– Text inputlabel– Form labelsmenubar– Application menu barnavigation-menu– Site navigationpopover– Floating panelsprogress– Progress indicatorsradio-group– Radio button groupsscroll-area– Custom scrollbarsselect– Dropdown selectsseparator– Visual dividerssheet– Side panelsskeleton– Loading placeholdersslider– Range inputswitch– Toggle switchtabs– Tab navigationtextarea– Multi-line inputtoast– Notification toaststoggle– Toggle buttontooltip– Hover tooltips
Theming
Color Customization
Change base color scheme:
# Regenerate components with new base color
npx shadcn-ui@latest init
# Choose new base: Slate, Gray, Zinc, Neutral, Stone
Manual color override (globals.css):
:root {
--primary: 210 100% 50%; /* HSL: Blue */
--primary-foreground: 0 0% 100%;
}
.dark {
--primary: 210 100% 60%; /* Lighter blue for dark mode */
}
Custom Variants
// Extend button variants
const buttonVariants = cva(
"...",
{
variants: {
variant: {
// ...existing variants
gradient: "bg-gradient-to-r from-purple-500 to-pink-500 text-white",
},
},
}
)
// Usage
<Button variant="gradient">Gradient Button</Button>
Theme Switching
// Using next-themes
import { ThemeProvider } from "next-themes"
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
)
}
// Theme toggle component
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
Dark Mode
Setup with Next.js
npm install next-themes
// app/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
)
}
// app/layout.tsx
import { Providers } from "./providers"
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Dark Mode Utilities
// Force dark mode for specific section
<div className="dark">
<Card>Always dark, regardless of theme</Card>
</div>
// Conditional styling
<div className="bg-white dark:bg-slate-950">
<p className="text-slate-900 dark:text-slate-50">
Adapts to theme
</p>
</div>
Next.js Integration
App Router Setup
# Create Next.js app with TypeScript and Tailwind
npx create-next-app@latest my-app --typescript --tailwind --app
# Initialize shadcn/ui
cd my-app
npx shadcn-ui@latest init
# Add components
npx shadcn-ui@latest add button card form
Server Components
// app/page.tsx (Server Component by default)
import { Button } from "@/components/ui/button"
export default function HomePage() {
return (
<main>
<h1>Welcome</h1>
{/* Static components work in Server Components */}
<Button asChild>
<a href="/about">Learn More</a>
</Button>
</main>
)
}
Client Components
// app/interactive.tsx
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
export function InteractiveSection() {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<p>Client-side interactivity</p>
</DialogContent>
</Dialog>
)
}
Route Handlers
// app/api/submit/route.ts
import { NextResponse } from "next/server"
import { z } from "zod"
const formSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
})
export async function POST(request: Request) {
try {
const body = await request.json()
const validatedData = formSchema.parse(body)
// Process form data
return NextResponse.json({ success: true })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ errors: error.errors }, { status: 400 })
}
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
Accessibility
ARIA Support
All shadcn/ui components include proper ARIA attributes via Radix UI:
// Dialog automatically includes:
// - role="dialog"
// - aria-describedby
// - aria-labelledby
// - Focus trap
// - Escape key handler
<Dialog>
<DialogContent>
{/* Automatically accessible */}
</DialogContent>
</Dialog>
// Button includes:
// - role="button"
// - tabindex="0"
// - Keyboard activation (Space/Enter)
<Button>Accessible by default</Button>
Keyboard Navigation
Built-in keyboard support:
Tab/Shift+Tab– Navigate between interactive elementsEnter/Space– Activate buttonsEscape– Close dialogs, dropdowns, popoversArrow keys– Navigate menus, select options, radio groupsHome/End– Jump to first/last in lists
Example: Command Palette:
import {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command"
// âK to open
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
<CommandItem>Calculator</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
Screen Reader Support
// Visually hidden but accessible to screen readers
<span className="sr-only">Close dialog</span>
// Skip navigation links
<a href="#main-content" className="sr-only focus:not-sr-only">
Skip to main content
</a>
// Descriptive labels
<FormLabel htmlFor="email">Email address</FormLabel>
<Input
id="email"
type="email"
aria-describedby="email-description"
aria-invalid={!!errors.email}
/>
<FormDescription id="email-description">
We'll never share your email.
</FormDescription>
Focus Management
// Focus trap in Dialog (automatic)
<Dialog>
<DialogContent>
{/* Focus stays within dialog until closed */}
</DialogContent>
</Dialog>
// Custom focus management
import { useRef, useEffect } from "react"
function CustomComponent() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
return <Input ref={inputRef} />
}
Composition Patterns
Compound Components
// Card composition
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>
// Form composition
<Form {...form}>
<FormField
control={form.control}
name="field"
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Help text</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</Form>
Polymorphic Components (asChild)
// Render Button as Link
import { Button } from "@/components/ui/button"
import Link from "next/link"
<Button asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
// Render as custom component
<Button asChild>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Animated Button
</motion.button>
</Button>
How it works (Radix Slot):
import { Slot } from "@radix-ui/react-slot"
interface ButtonProps {
asChild?: boolean
}
const Button = ({ asChild, ...props }: ButtonProps) => {
const Comp = asChild ? Slot : "button"
return <Comp {...props} />
}
Custom Compositions
// Create custom card variant
export function PricingCard({
title,
price,
features,
highlighted
}: PricingCardProps) {
return (
<Card className={cn(highlighted && "border-primary")}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription className="text-3xl font-bold">
${price}/mo
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{features.map((feature) => (
<li key={feature} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-primary" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button className="w-full" variant={highlighted ? "default" : "outline"}>
Get Started
</Button>
</CardFooter>
</Card>
)
}
CLI Commands
Initialize
# Interactive init
npx shadcn-ui@latest init
# Non-interactive with defaults
npx shadcn-ui@latest init -y
# Specify options
npx shadcn-ui@latest init --typescript --tailwind
Add Components
# Single component
npx shadcn-ui@latest add button
# Multiple components
npx shadcn-ui@latest add button card dialog form
# All components (not recommended - adds everything)
npx shadcn-ui@latest add --all
# Specific version
npx shadcn-ui@latest add button@1.0.0
# Overwrite existing
npx shadcn-ui@latest add button --overwrite
# Different path
npx shadcn-ui@latest add button --path src/components/ui
Diff Components
# Check for component updates
npx shadcn-ui@latest diff
# Diff specific component
npx shadcn-ui@latest diff button
# Show what would change
npx shadcn-ui@latest diff --check
Update Components
# Update all components
npx shadcn-ui@latest update
# Update specific components
npx shadcn-ui@latest update button card
# Preview changes before applying
npx shadcn-ui@latest update --dry-run
Advanced Patterns
Custom Hooks
// useToast hook (built-in with toast component)
import { useToast } from "@/components/ui/use-toast"
function MyComponent() {
const { toast } = useToast()
return (
<Button
onClick={() => {
toast({
title: "Scheduled: Catch up",
description: "Friday, February 10, 2023 at 5:57 PM",
})
}}
>
Show Toast
</Button>
)
}
// Custom form hook
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
function useFormWithToast<T extends z.ZodType>(schema: T) {
const { toast } = useToast()
const form = useForm({
resolver: zodResolver(schema),
})
const handleSubmit = form.handleSubmit(async (data) => {
try {
// Submit logic
toast({ title: "Success!" })
} catch (error) {
toast({ title: "Error", variant: "destructive" })
}
})
return { form, handleSubmit }
}
Responsive Design
// Mobile-first responsive components
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>Mobile: 1 col, Tablet: 2 col, Desktop: 3 col</Card>
</div>
// Responsive dialog (sheet on mobile, dialog on desktop)
import { useMediaQuery } from "@/hooks/use-media-query"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Sheet, SheetContent } from "@/components/ui/sheet"
function ResponsiveModal({ children, ...props }) {
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) {
return (
<Dialog {...props}>
<DialogContent>{children}</DialogContent>
</Dialog>
)
}
return (
<Sheet {...props}>
<SheetContent>{children}</SheetContent>
</Sheet>
)
}
Animation Variants
// Using Framer Motion with shadcn/ui
import { motion } from "framer-motion"
import { Card } from "@/components/ui/card"
const MotionCard = motion(Card)
<MotionCard
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
Animated Card
</MotionCard>
// Staggered list animation
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
}
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 }
}
<motion.ul variants={container} initial="hidden" animate="show">
{items.map((item) => (
<motion.li key={item.id} variants={item}>
<Card>{item.content}</Card>
</motion.li>
))}
</motion.ul>
Best Practices
Code Organization:
- Keep shadcn/ui components in
components/ui/(don’t mix with app components) - Create custom compositions in
components/(outside ui/) - Use
lib/utils.tsfor shared utilities
Customization:
- Modify components directly in your project (you own the code)
- Use CSS variables for theme-wide changes
- Extend variants with CVA for new styles
- Don’t edit
components.jsonmanually (use CLI)
Performance:
- Tree-shaking automatic (only imports what you use)
- Use
asChildto avoid unnecessary wrapper elements - Lazy load heavy components (Calendar, Command)
- Prefer Server Components when possible (Next.js)
Accessibility:
- Don’t remove ARIA attributes from components
- Test keyboard navigation for custom compositions
- Maintain focus management in dialogs/modals
- Use semantic HTML with
asChildwhen applicable
TypeScript:
- Leverage exported types (ButtonProps, CardProps, etc.)
- Use VariantProps for variant type safety
- Add strict null checks for form validation
Troubleshooting
Import errors:
# Check path aliases in tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
Tailwind classes not applying:
// Ensure content paths include your components
// tailwind.config.ts
content: [
'./src/components/**/*.{ts,tsx}', // Add this
'./src/app/**/*.{ts,tsx}',
]
Dark mode not working:
// Add suppressHydrationWarning to <html>
<html lang="en" suppressHydrationWarning>
Form validation not triggering:
// Ensure FormMessage is included in FormField
<FormField>
<FormItem>
<FormControl>...</FormControl>
<FormMessage /> {/* Required for errors */}
</FormItem>
</FormField>
Resources
- Official Docs: https://ui.shadcn.com
- Component Source: https://github.com/shadcn-ui/ui
- Radix UI Docs: https://www.radix-ui.com
- Tailwind CSS: https://tailwindcss.com
- CVA Docs: https://cva.style/docs
- Examples: https://ui.shadcn.com/examples
- Community: https://discord.gg/shadcn-ui