| name | shadcn-tailwind |
| description | UI development with shadcn/ui components, Tailwind CSS v4, and Framer Motion animations. Use when building user interfaces, styling components, implementing animations, creating responsive layouts, or adding interactive feedback. Triggers on shadcn, tailwind, framer-motion, components, UI, styling, animations, responsive. |
shadcn/ui + Tailwind v4 + Framer Motion
Build polished UI components with shadcn/ui, style with Tailwind CSS v4, and add fluid animations with Framer Motion.
When to Use This Skill
- Building UI components with shadcn/ui
- Styling with Tailwind CSS v4
- Adding animations with Framer Motion
- Creating responsive layouts
- Implementing interactive feedback
- Building accessible components
Tailwind v4 Configuration
Setup with CSS Variables
@import "tailwindcss";
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(222.2 84% 4.9%);
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(222.2 84% 4.9%);
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(222.2 84% 4.9%);
--color-primary: hsl(221.2 83.2% 53.3%);
--color-primary-foreground: hsl(210 40% 98%);
--color-secondary: hsl(210 40% 96.1%);
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
--color-muted: hsl(210 40% 96.1%);
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
--color-accent: hsl(210 40% 96.1%);
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(214.3 31.8% 91.4%);
--color-input: hsl(214.3 31.8% 91.4%);
--color-ring: hsl(221.2 83.2% 53.3%);
--radius-lg: 0.5rem;
--radius-md: calc(var(--radius-lg) - 2px);
--radius-sm: calc(var(--radius-lg) - 4px);
--font-sans: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
.dark {
--color-background: hsl(222.2 84% 4.9%);
--color-foreground: hsl(210 40% 98%);
--color-card: hsl(222.2 84% 4.9%);
--color-card-foreground: hsl(210 40% 98%);
--color-popover: hsl(222.2 84% 4.9%);
--color-popover-foreground: hsl(210 40% 98%);
--color-primary: hsl(217.2 91.2% 59.8%);
--color-primary-foreground: hsl(222.2 47.4% 11.2%);
--color-secondary: hsl(217.2 32.6% 17.5%);
--color-secondary-foreground: hsl(210 40% 98%);
--color-muted: hsl(217.2 32.6% 17.5%);
--color-muted-foreground: hsl(215 20.2% 65.1%);
--color-accent: hsl(217.2 32.6% 17.5%);
--color-accent-foreground: hsl(210 40% 98%);
--color-destructive: hsl(0 62.8% 30.6%);
--color-destructive-foreground: hsl(210 40% 98%);
--color-border: hsl(217.2 32.6% 17.5%);
--color-input: hsl(217.2 32.6% 17.5%);
--color-ring: hsl(224.3 76.3% 48%);
}
Utility Classes
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
shadcn/ui Components
Button with Variants
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-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',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
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}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Card Component
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
Framer Motion Animations
Page Transitions
import { motion, AnimatePresence } from 'framer-motion';
import { useLocation } from 'react-router-dom';
const pageVariants = {
initial: {
opacity: 0,
x: -20,
},
enter: {
opacity: 1,
x: 0,
transition: {
duration: 0.3,
ease: 'easeOut',
},
},
exit: {
opacity: 0,
x: 20,
transition: {
duration: 0.2,
ease: 'easeIn',
},
},
};
export function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
initial="initial"
animate="enter"
exit="exit"
variants={pageVariants}
>
{children}
</motion.div>
</AnimatePresence>
);
}
List Animations
import { motion, AnimatePresence } from 'framer-motion';
interface AnimatedListProps<T> {
items: T[];
keyExtractor: (item: T) => string;
renderItem: (item: T, index: number) => React.ReactNode;
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: i * 0.05,
duration: 0.3,
ease: 'easeOut',
},
}),
exit: {
opacity: 0,
y: -10,
transition: {
duration: 0.2,
},
},
};
export function AnimatedList<T>({
items,
keyExtractor,
renderItem,
}: AnimatedListProps<T>) {
return (
<AnimatePresence mode="popLayout">
{items.map((item, index) => (
<motion.div
key={keyExtractor(item)}
custom={index}
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
layout
>
{renderItem(item, index)}
</motion.div>
))}
</AnimatePresence>
);
}
Interactive Buttons
import { motion } from 'framer-motion';
import { Button, type ButtonProps } from '@/components/ui/button';
export function InteractiveButton({ children, ...props }: ButtonProps) {
return (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button {...props}>{children}</Button>
</motion.div>
);
}
Collapsible Panel
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CollapsiblePanelProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
export function CollapsiblePanel({
title,
children,
defaultOpen = false,
}: CollapsiblePanelProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border rounded-lg overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 hover:bg-accent transition-colors"
>
<span className="font-medium">{title}</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-5 h-5" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div className="p-4 pt-0">{children}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Loading States
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const sizeMap = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
};
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
return (
<motion.div
className={cn(sizeMap[size], 'border-2 border-primary border-t-transparent rounded-full', className)}
animate={{ rotate: 360 }}
transition={{
duration: 1,
repeat: Infinity,
ease: 'linear',
}}
/>
);
}
export function Skeleton({ className }: { className?: string }) {
return (
<motion.div
className={cn('bg-muted rounded', className)}
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
);
}
Status Indicators
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import type { TaskStatus, SessionStatus } from '@agentop/core';
interface StatusBadgeProps {
status: TaskStatus | SessionStatus;
}
const statusConfig = {
pending: {
color: 'bg-gray-500',
pulse: false,
},
in_progress: {
color: 'bg-blue-500',
pulse: true,
},
running: {
color: 'bg-blue-500',
pulse: true,
},
completed: {
color: 'bg-green-500',
pulse: false,
},
blocked: {
color: 'bg-yellow-500',
pulse: false,
},
error: {
color: 'bg-red-500',
pulse: false,
},
cancelled: {
color: 'bg-gray-400',
pulse: false,
},
};
export function StatusBadge({ status }: StatusBadgeProps) {
const config = statusConfig[status] || statusConfig.pending;
return (
<span className="relative flex h-3 w-3">
{config.pulse && (
<motion.span
className={cn(
'absolute inline-flex h-full w-full rounded-full opacity-75',
config.color
)}
animate={{ scale: [1, 1.5, 1], opacity: [0.75, 0, 0.75] }}
transition={{ duration: 1.5, repeat: Infinity }}
/>
)}
<span
className={cn(
'relative inline-flex rounded-full h-3 w-3',
config.color
)}
/>
</span>
);
}
Responsive Patterns
Responsive Sidebar
import { motion, AnimatePresence } from 'framer-motion';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { cn } from '@/lib/utils';
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Sidebar({ isOpen, onClose, children }: SidebarProps) {
const isDesktop = useMediaQuery('(min-width: 1024px)');
if (isDesktop) {
return (
<aside className="w-64 border-r bg-card h-full flex-shrink-0">
{children}
</aside>
);
}
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 z-40"
/>
{/* Sidebar */}
<motion.aside
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 w-72 h-full bg-card border-r z-50 shadow-xl"
>
{children}
</motion.aside>
</>
)}
</AnimatePresence>
);
}
Media Query Hook
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const media = window.matchMedia(query);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}
Best Practices
- Use cn() for class merging - Prevents Tailwind class conflicts
- Leverage CSS variables - Enables theming and dark mode
- AnimatePresence for exit animations - Animate elements when removed
- layout prop for smooth reflows - Auto-animate layout changes
- Reduce motion for accessibility - Respect user preferences
- Extract animation variants - Reusable animation definitions
Troubleshooting
| Issue | Solution |
|---|
| Tailwind classes not applying | Check @import order in CSS |
| Animation jank | Use transform properties, enable GPU acceleration |
| Flash on theme change | Add suppressHydrationWarning to html |
| Component flicker | Wrap in AnimatePresence with mode="wait" |