| name | shadcn-ui-best-practices |
| description | Guide for proper shadcn-ui component usage - use Card for wrapping/layout, compose from base components, never modify components/ui directly |
shadcn-ui Best Practices
This guide covers best practices for working with shadcn-ui components in this Vite + React + TypeScript boilerplate.
Core Principles
1. Never Modify components/ui/ Directly
CRITICAL RULE: Components in src/components/ui/ are base primitives generated by shadcn-ui CLI. Never edit these files directly.
Why?
- These files can be regenerated or updated by the CLI
- Manual changes will be lost when updating components
- Breaking the abstraction makes maintenance difficult
What to do instead: Compose new components in src/components/shared/ that use these primitives.
Directory Structure
src/
├── components/
│ ├── ui/ # Base shadcn primitives (DO NOT MODIFY)
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── badge.tsx
│ │ └── ...
│ └── shared/ # Your composed app components (CREATE HERE)
│ ├── FeatureCard.tsx
│ ├── StatCard.tsx
│ └── ...
├── pages/ # Page components (USE ui components here)
│ └── Home.tsx
└── lib/
└── utils.ts # cn() utility for className merging
Card Component Usage
When to Use Card
Cards are the go-to component for:
- Wrapping and layout: Creating visual containers for content
- Grouping related content: Features, stats, user info, etc.
- Creating sections: Distinct areas in your UI
- Interactive elements: Clickable panels, hover effects
Card Anatomy
shadcn-ui Card comes with semantic sub-components:
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from '@/components/ui/card';
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
{/* Main content */}
</CardContent>
<CardFooter>
{/* Actions or metadata */}
</CardFooter>
</Card>
Real Example from Home.tsx
// Feature cards with hover effects and gradient borders
<Card
key={feature.title}
className="group relative overflow-hidden border-2 transition-all duration-300 hover:-translate-y-2 hover:border-transparent hover:shadow-2xl"
>
{/* Gradient border effect on hover */}
<div
className={`absolute inset-0 -z-10 bg-gradient-to-br ${feature.gradient} opacity-0 transition-opacity duration-300 group-hover:opacity-100`}
/>
<div className="absolute inset-[2px] -z-10 rounded-lg bg-white" />
<CardHeader className="space-y-4">
<div className="flex items-start justify-between">
<div className={`rounded-xl bg-gradient-to-br ${feature.gradient} p-3`}>
<Icon className="h-6 w-6 text-white" />
</div>
<Badge>{feature.badge}</Badge>
</div>
<div>
<CardTitle className="mb-2 text-2xl">{feature.title}</CardTitle>
<CardDescription className="text-base">
{feature.description}
</CardDescription>
</div>
</CardHeader>
</Card>
Key patterns demonstrated:
- Using
classNameto extend base styles without modifying the component - Combining multiple shadcn primitives (Card + Badge)
- Adding custom elements (gradient divs) while preserving semantic structure
- Using Tailwind's
grouputilities for interactive effects
Component Composition Patterns
Using class-variance-authority (CVA)
shadcn-ui uses CVA for managing component variants. This is the standard pattern:
// From src/components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Base styles (always applied)
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
Creating Custom Variants (Without Modifying components/ui)
If you need custom button variants, create a composed component:
// src/components/shared/GradientButton.tsx
import { forwardRef } from 'react';
import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface GradientButtonProps extends ButtonProps {
gradient?: 'blue' | 'purple' | 'pink';
}
export const GradientButton = forwardRef<HTMLButtonElement, GradientButtonProps>(
({ gradient = 'blue', className, ...props }, ref) => {
const gradients = {
blue: 'bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600',
purple: 'bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600',
pink: 'bg-gradient-to-r from-pink-500 to-rose-500 hover:from-pink-600 hover:to-rose-600',
};
return (
<Button
ref={ref}
className={cn(gradients[gradient], 'text-white shadow-lg', className)}
{...props}
/>
);
}
);
GradientButton.displayName = 'GradientButton';
Key patterns:
- Extends
ButtonPropsfor full type safety - Uses composition (wraps
Button) instead of modification - Uses
forwardReffor ref forwarding - Sets
displayNamefor better debugging
TypeScript Patterns
forwardRef with Proper Types
All shadcn components use React.forwardRef for ref forwarding:
// From src/components/ui/card.tsx
const Card = React.forwardRef<
HTMLDivElement, // Ref type
React.HTMLAttributes<HTMLDivElement> // Props type
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
Type anatomy:
- First generic: Element type the ref points to
- Second generic: Props interface
- Always set
displayNamefor React DevTools
Extending Component Props
Use intersection types to extend base component props:
// From src/components/ui/badge.tsx
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
Benefits:
- Full HTML element props (onClick, aria-*, etc.)
- Type-safe variant props
- Intellisense for all valid props
The cn() Utility
What It Does
Located in src/lib/utils.ts:
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Purpose:
clsx: Conditionally combine classNamestwMerge: Intelligently merge Tailwind classes (later classes override earlier ones)
Usage Patterns
// Conditional classes
<Card className={cn(
'base-class',
isActive && 'active-class',
isDisabled && 'disabled-class',
className // Always pass through user className last
)} />
// Merging with base styles
<Button className={cn(
buttonVariants({ variant, size }), // Base variants
className // User overrides
)} />
// Complex conditions
<div className={cn(
'flex items-center',
orientation === 'vertical' ? 'flex-col' : 'flex-row',
spacing === 'lg' ? 'gap-4' : 'gap-2',
className
)} />
Always:
- Put user's
classNameprop last so they can override - Use
cn()instead of template literals for Tailwind classes
Accessibility-First Approach
Leveraging Radix UI Primitives
shadcn-ui is built on Radix UI, which provides accessible primitives out of the box.
Example from Button:
import { Slot } from '@radix-ui/react-slot';
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
asChild pattern:
- Allows Button to render as any element (e.g., Link)
- Preserves Button styles while changing semantics
- Maintains accessibility of the underlying element
// Button as a Link
<Button asChild>
<a href="/about">About</a>
</Button>
Always Spread Props
<div {...props} /> // Allows aria-*, role, data-* attributes
This ensures consumers can add accessibility attributes without modification.
Import Patterns
Barrel Exports
Components in components/ui/ use barrel exports:
// Good: Named exports allow tree-shaking
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
// Usage: Import only what you need
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
Path Aliases
Use the @/ alias configured in tsconfig.json:
// Good
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
// Avoid
import { Card } from '../../../components/ui/card';
import { cn } from '../../lib/utils';
Do's and Don'ts
DO
- Use Card for layout and grouping related content
- Compose new components in
components/shared/when you need custom behavior - Use
classNameprop to extend styles - Use
cn()utility for combining classes - Spread
{...props}to allow consumer customization - Use
forwardRefwhen creating composed components - Export variant functions (e.g.,
buttonVariants) for reuse - Leverage CVA for managing variants
- Keep semantic HTML structure (CardHeader, CardContent, CardFooter)
DON'T
- Modify files in
components/ui/directly - Hardcode colors (use Tailwind's theme system)
- Skip forwardRef when composing components that need refs
- Forget to set
displayNameon forwardRef components - Use string concatenation for classNames (use
cn()instead) - Create one-off variants by modifying base components
- Skip TypeScript types (always type your props)
- Override Radix UI accessibility features without understanding them
Common Patterns from This Codebase
Pattern 1: Feature Cards with Icons
// Home.tsx pattern
const features = [
{
title: 'Feature Name',
badge: 'Badge Text',
icon: IconComponent,
description: 'Description text',
gradient: 'from-violet-500 to-purple-500',
},
];
<Card className="group relative overflow-hidden">
<CardHeader>
<div className="flex items-start justify-between">
<div className={`rounded-xl bg-gradient-to-br ${gradient} p-3`}>
<Icon className="h-6 w-6 text-white" />
</div>
<Badge>{badge}</Badge>
</div>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
</Card>
Pattern 2: Interactive Cards with Hover Effects
<Card className="group transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl">
{/* Use Tailwind group utilities for coordinated animations */}
<div className="transition-transform duration-300 group-hover:scale-110">
{/* Content */}
</div>
</Card>
Pattern 3: Grid Layouts with Cards
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<Card key={item.id}>
{/* Card content */}
</Card>
))}
</div>
Extending shadcn Components: Complete Example
Creating a custom StatCard component in components/shared/:
// src/components/shared/StatCard.tsx
import { forwardRef } from 'react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
export interface StatCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
value: string | number;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
description?: string;
}
export const StatCard = forwardRef<HTMLDivElement, StatCardProps>(
({ title, value, icon: Icon, trend, description, className, ...props }, ref) => {
return (
<Card
ref={ref}
className={cn('transition-all hover:shadow-lg', className)}
{...props}
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{trend && (
<Badge
variant={trend.isPositive ? 'default' : 'destructive'}
className="mt-2"
>
{trend.isPositive ? '+' : ''}
{trend.value}%
</Badge>
)}
{description && (
<p className="mt-2 text-xs text-muted-foreground">{description}</p>
)}
</CardContent>
</Card>
);
}
);
StatCard.displayName = 'StatCard';
Usage:
import { StatCard } from '@/components/shared/StatCard';
import { Users } from 'lucide-react';
<StatCard
title="Total Users"
value="2,543"
icon={Users}
trend={{ value: 12.5, isPositive: true }}
description="Up from last month"
/>
Summary Checklist
When working with shadcn-ui in this project:
- Need to modify a component? Create a new one in
components/shared/ - Creating a composed component? Use
forwardRefand setdisplayName - Need custom variants? Use CVA pattern or className composition
- Combining classes? Use the
cn()utility - Need a container? Consider using Card with semantic sub-components
- Creating interactive elements? Use Tailwind's
grouputilities - Need type safety? Extend base component props with intersection types
- Importing? Use
@/path alias and named imports - Adding functionality? Spread
{...props}to preserve flexibility - Updating shadcn components? Use CLI, never manual edits to
components/ui/