Use when building React UIs with shadcn/ui that need cohesive, distinctive styling beyond defaults - provides systematic approach to theme tokens, component variants, related component groups, animations, and design system coherence. Transforms uniform shadcn into memorable branded experiences.
This skill transforms shadcn/ui from a uniform component library into a cohesive, distinctive design system. The key insight: shadcn components share visual DNA through CSS variables, Tailwind classes, and structural patterns. When you customize one aspect (like border radius), you must propagate that change to all related components to maintain coherence.
Core principle: Every customization creates ripples. A design system is a network of interconnected decisions, not isolated component tweaks.
Use this skill when:
Don't use when:
Theme Tokens (globals.css)
↓
Component Families (related components)
↓
Individual Variants (cva patterns)
↓
Instance Overrides (className props)
Always work top-down. Token changes cascade automatically; component changes require manual propagation to siblings.
shadcn/ui uses a layered CSS variable system. Understanding this prevents broken themes:
/* Layer 1: Raw values in :root */
:root {
--radius: 0.625rem;
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
}
/* Layer 2: Theme mapping via @theme inline */
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
/* Layer 3: Components consume mapped values */
/* Button uses: rounded-lg (--radius-lg), bg-primary, text-primary-foreground */
| Token Category | Variables | Used By |
|---|---|---|
| Radius | --radius, --radius-sm/md/lg/xl | All interactive elements |
| Colors - Core | --background, --foreground | Body, text |
| Colors - Surfaces | --card, --popover, --muted | Cards, dialogs, sheets, menus |
| Colors - Interactive | --primary, --secondary, --accent | Buttons, links, selections |
| Colors - States | --destructive, --ring, --border, --input | Errors, focus, borders |
| Colors - Charts | --chart-1 through --chart-5 | Charts, data viz |
| Colors - Sidebar | --sidebar-* variants | Sidebar component family |
Step 1: Define your palette in OKLCH (better perceptual uniformity than HSL):
:root {
/* Brand colors */
--primary: oklch(0.65 0.25 250); /* Vibrant blue */
--primary-foreground: oklch(0.98 0.01 250);
/* Semantic adjustments */
--destructive: oklch(0.55 0.22 25); /* Rich red */
--accent: oklch(0.75 0.15 180); /* Teal accent */
/* Surface hierarchy (maintain contrast) */
--background: oklch(0.99 0.005 250);
--card: oklch(1 0 0);
--muted: oklch(0.96 0.01 250);
--popover: oklch(1 0 0);
}
.dark {
--primary: oklch(0.75 0.2 250); /* Lighter for dark mode */
--primary-foreground: oklch(0.15 0.02 250);
--background: oklch(0.13 0.02 250);
--card: oklch(0.18 0.02 250);
--muted: oklch(0.22 0.02 250);
--popover: oklch(0.2 0.02 250);
}
Step 2: Adjust radius for personality:
:root {
/* Sharp/Technical: */
--radius: 0.25rem;
/* Balanced: */
--radius: 0.5rem;
/* Soft/Friendly: */
--radius: 0.75rem;
/* Pill-like: */
--radius: 1rem;
/* Fully rounded: */
--radius: 9999px;
}
Step 3: Extend with custom tokens:
:root {
/* Custom semantic colors */
--success: oklch(0.65 0.2 145);
--success-foreground: oklch(0.98 0.02 145);
--warning: oklch(0.8 0.18 85);
--warning-foreground: oklch(0.25 0.05 85);
--info: oklch(0.7 0.15 230);
--info-foreground: oklch(0.98 0.01 230);
}
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
}
When you modify one component's visual properties, related components become inconsistent. Here are the families you must update together:
| Component | Shared Properties |
|---|---|
| Dialog | rounded-lg, border, bg-background, shadow, animation |
| Sheet | Same surface treatment, different positioning |
| Popover | Same but smaller, arrow optional |
| DropdownMenu | Same surface, specific item patterns |
| ContextMenu | Identical to DropdownMenu |
| Tooltip | Simplified surface, different timing |
| HoverCard | Between Tooltip and Popover |
| AlertDialog | Dialog variant with action emphasis |
| Command | Dialog variant with search pattern |
When customizing overlays, update ALL of these:
// Example: Adding consistent glass morphism to all overlays
// In each component file, update the content wrapper:
// dialog.tsx
const DialogContent = React.forwardRef<...>(({ className, ...props }, ref) => (
<DialogPrimitive.Content
className={cn(
"bg-background/80 backdrop-blur-xl border-white/20",
"shadow-2xl shadow-black/10",
// ... rest of classes
className
)}
{...props}
/>
))
// Apply same pattern to: SheetContent, PopoverContent,
// DropdownMenuContent, TooltipContent, HoverCardContent
| Component | Shared Properties |
|---|---|
| Input | Height, padding, border, focus ring |
| Textarea | Same but multi-line |
| Select | Input-like trigger, overlay content |
| Combobox | Input + dropdown hybrid |
| DatePicker | Input trigger + calendar overlay |
| InputOTP | Multiple small inputs |
Unified form control styling:
// Create shared input styles
const inputBaseStyles = cn(
'h-11 px-4', // Consistent sizing
'rounded-lg', // Match radius
'border-2 border-input', // Visible borders
'bg-transparent', // Or bg-muted/50
'transition-all duration-200', // Smooth states
'focus-visible:ring-2 focus-visible:ring-ring/50',
'focus-visible:border-primary',
'placeholder:text-muted-foreground/60'
);
// Apply to Input, Textarea, Select trigger, etc.
| Component | Shared Properties |
|---|---|
| Button | Core interactive element |
| Toggle | Button variant for on/off |
| ToggleGroup | Multiple toggles |
| ButtonGroup | Multiple buttons |
| Tabs (triggers) | Button-like triggers |
| Pagination | Button-based navigation |
Unified button treatment:
// button.tsx - Define comprehensive variants
const buttonVariants = cva(
cn(
'inline-flex items-center justify-center gap-2',
'font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
// Add micro-interaction
'active:scale-[0.98]'
),
{
variants: {
variant: {
default: cn(
'bg-primary text-primary-foreground',
'hover:bg-primary/90',
'shadow-md shadow-primary/25',
'hover:shadow-lg hover:shadow-primary/30'
),
secondary: cn(
'bg-secondary text-secondary-foreground',
'hover:bg-secondary/80',
'border border-border'
),
outline: cn(
'border-2 border-input bg-transparent',
'hover:bg-accent hover:text-accent-foreground',
'hover:border-accent'
),
ghost: cn('hover:bg-accent/50 hover:text-accent-foreground'),
destructive: cn(
'bg-destructive text-destructive-foreground',
'hover:bg-destructive/90',
'shadow-md shadow-destructive/25'
),
// Custom variants
success: cn(
'bg-success text-success-foreground',
'hover:bg-success/90'
),
link: cn('text-primary underline-offset-4', 'hover:underline'),
},
size: {
default: 'h-10 px-5 py-2',
sm: 'h-8 px-3 text-sm',
lg: 'h-12 px-8 text-lg',
xl: 'h-14 px-10 text-xl',
icon: 'h-10 w-10',
'icon-sm': 'h-8 w-8',
'icon-lg': 'h-12 w-12',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
| Component | Shared Properties |
|---|---|
| Card | Container with consistent padding, border |
| Table | Row styling, cell padding |
| DataTable | Table + interactions |
| Accordion | Expandable sections |
| Collapsible | Simple expand/collapse |
| Component | Shared Properties |
|---|---|
| NavigationMenu | Top-level nav |
| Sidebar | Side navigation |
| Breadcrumb | Location trail |
| Tabs | Section switching |
| Menubar | Application menu |
shadcn uses tw-animate-css for enter/exit animations. Customize timing and easing globally:
/* In globals.css, after imports */
:root {
/* Animation timing */
--animation-duration: 200ms;
--animation-duration-slow: 400ms;
--animation-duration-fast: 150ms;
/* Easing curves */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out-expo: cubic-bezier(0.87, 0, 0.13, 1);
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@theme inline {
--animate-duration: var(--animation-duration);
}
Approach 1: CSS Keyframes (simpler)
@keyframes slide-up-fade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Apply to components */
.animate-slide-up-fade {
animation: slide-up-fade var(--animation-duration) var(--ease-out-expo);
}
Approach 2: Motion library (React, more control)
import { motion, AnimatePresence } from "framer-motion"
// Wrap Dialog content
const DialogContent = React.forwardRef<...>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay asChild>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/80"
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content asChild ref={ref} {...props}>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className={cn("fixed left-1/2 top-1/2 ...", className)}
>
{children}
</motion.div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
// Button press effect
'active:scale-[0.98] transition-transform duration-100';
// Hover lift
'hover:-translate-y-0.5 hover:shadow-lg transition-all duration-200';
// Focus glow
'focus-visible:ring-4 focus-visible:ring-primary/20 transition-shadow duration-200';
// Skeleton loading
'animate-pulse bg-muted';
// Or shimmer:
'bg-gradient-to-r from-muted via-muted/50 to-muted bg-[length:200%_100%] animate-shimmer';
Step 1: Import fonts (using next/font, Google Fonts, or local)
// app/layout.tsx (Next.js example)
import { Instrument_Serif, DM_Sans } from 'next/font/google';
const displayFont = Instrument_Serif({
subsets: ['latin'],
weight: '400',
variable: '--font-display',
});
const bodyFont = DM_Sans({
subsets: ['latin'],
variable: '--font-body',
});
export default function RootLayout({ children }) {
return (
<html className={`${displayFont.variable} ${bodyFont.variable}`}>
{children}
</html>
);
}
Step 2: Configure in CSS
@theme inline {
--font-sans: var(--font-body), system-ui, sans-serif;
--font-display: var(--font-display), Georgia, serif;
}
@layer base {
body {
font-family: var(--font-sans);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
}
}
Step 3: Apply to components
// card.tsx - Use display font for titles
const CardTitle = React.forwardRef<...>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"font-display text-2xl font-semibold tracking-tight",
className
)}
{...props}
/>
))
When making ANY customization, check these for consistency:
When changing primary:
// Instead of multiple component variants, use data attributes
<Card data-variant="elevated">
<Card data-variant="outlined">
<Card data-variant="filled">
// Style with attribute selectors
const Card = React.forwardRef<...>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg",
// Base styles
"data-[variant=elevated]:bg-card data-[variant=elevated]:shadow-lg",
"data-[variant=outlined]:border-2 data-[variant=outlined]:border-border",
"data-[variant=filled]:bg-muted",
className
)}
{...props}
/>
))
// Use CSS custom properties for internal spacing
const Card = React.forwardRef<...>(({ className, ...props }, ref) => (
<div
ref={ref}
style={{ '--card-padding': '1.5rem' } as React.CSSProperties}
className={cn("rounded-lg border bg-card", className)}
{...props}
/>
))
const CardHeader = ({ className, ...props }) => (
<div
className={cn(
"px-[var(--card-padding)] pt-[var(--card-padding)]",
className
)}
{...props}
/>
)
const CardContent = ({ className, ...props }) => (
<div
className={cn("px-[var(--card-padding)]", className)}
{...props}
/>
)
const CardFooter = ({ className, ...props }) => (
<div
className={cn(
"px-[var(--card-padding)] pb-[var(--card-padding)]",
className
)}
{...props}
/>
)
:root {
--shadow-color: 220 3% 15%;
--shadow-elevation-low:
0.3px 0.5px 0.7px hsl(var(--shadow-color) / 0.1),
0.4px 0.8px 1px -1.2px hsl(var(--shadow-color) / 0.1),
1px 2px 2.5px -2.5px hsl(var(--shadow-color) / 0.1);
--shadow-elevation-medium:
0.3px 0.5px 0.7px hsl(var(--shadow-color) / 0.11),
0.8px 1.6px 2px -0.8px hsl(var(--shadow-color) / 0.11),
2.1px 4.1px 5.2px -1.7px hsl(var(--shadow-color) / 0.11),
5px 10px 12.6px -2.5px hsl(var(--shadow-color) / 0.11);
}
.dark {
--shadow-color: 220 40% 2%;
}
@theme inline {
--shadow-sm: var(--shadow-elevation-low);
--shadow-md: var(--shadow-elevation-medium);
}
| File | Purpose |
|---|---|
globals.css | Theme tokens, base styles |
components/ui/*.tsx | Individual component customization |
lib/utils.ts | cn() helper |
components.json | shadcn CLI config |
tailwind.config.ts | Extended theme (v3) / not used (v4) |
| Pattern | Classes |
|---|---|
| Elevated surface | bg-card shadow-md |
| Subtle surface | bg-muted/50 |
| Interactive hover | hover:bg-accent/50 transition-colors |
| Focus ring | focus-visible:ring-2 focus-visible:ring-ring |
| Disabled state | disabled:opacity-50 disabled:pointer-events-none |
| Press feedback | active:scale-[0.98] |
/* Darken: reduce L (lightness) */
--primary: oklch(0.65 0.25 250);
--primary-hover: oklch(0.55 0.25 250);
/* Lighten: increase L */
--primary-light: oklch(0.75 0.25 250);
/* Desaturate: reduce C (chroma) */
--primary-muted: oklch(0.65 0.1 250);
/* Shift hue: change H */
--primary-complement: oklch(0.65 0.25 70); /* 250 + 180 = 430 → 70 */
| Mistake | Fix |
|---|---|
| Changing radius only in Button | Update all Family 1 & 2 components |
| Hardcoding colors in components | Use CSS variables everywhere |
| Forgetting dark mode | Always define .dark variants |
| Inconsistent animation timing | Use global timing variables |
| Breaking focus states | Keep focus ring for accessibility |
| Mixing color formats | Stick to OKLCH for consistency |
| Over-animating | Choose 2-3 high-impact moments, not everything |
This skill pairs with frontend-design. Use that skill's aesthetic direction guidance, then implement here:
Example flow: