Initial commit
This commit is contained in:
977
skills/web-artifacts-builder/SKILL.md
Normal file
977
skills/web-artifacts-builder/SKILL.md
Normal file
@@ -0,0 +1,977 @@
|
||||
---
|
||||
name: web-artifacts-builder
|
||||
description: Modern web development patterns using React + Tailwind CSS + shadcn/ui for building production-quality, accessible, and performant web applications
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive patterns and best practices for building modern web applications using the React + Tailwind CSS + shadcn/ui stack. It emphasizes component-driven development, type safety with TypeScript, accessibility, and performance optimization.
|
||||
|
||||
## Stack Overview
|
||||
|
||||
**Core Technologies**:
|
||||
- **React 18+**: Component-based UI with hooks and concurrent features
|
||||
- **TypeScript**: Type-safe development with excellent IDE support
|
||||
- **Tailwind CSS**: Utility-first CSS framework for rapid styling
|
||||
- **shadcn/ui**: High-quality, accessible React components built on Radix UI
|
||||
- **Vite**: Fast build tool with HMR and optimized production builds
|
||||
|
||||
**Why This Stack**:
|
||||
- Type safety reduces bugs and improves maintainability
|
||||
- Tailwind enables rapid UI development without context switching
|
||||
- shadcn/ui provides accessible, customizable components out of the box
|
||||
- Vite offers excellent developer experience and fast builds
|
||||
- Modern ecosystem with active community support
|
||||
|
||||
**Design Considerations**:
|
||||
Based on research from ["Improving frontend design through Skills"](https://claude.com/blog/improving-frontend-design-through-skills):
|
||||
- **Avoid Distributional Defaults**: Don't use Inter/Roboto/Open Sans, purple gradients, or plain backgrounds
|
||||
- **Distinctive Typography**: Use high-contrast font pairings with extreme weight variations (100-200 or 800-900)
|
||||
- **Intentional Colors**: Move beyond generic color schemes with thoughtful palettes
|
||||
- **High-Impact Motion**: One well-orchestrated page load beats a dozen random animations
|
||||
- **Motion Library**: Use Framer Motion for complex animation choreography in React
|
||||
- See `frontend-aesthetics` skill for comprehensive design guidance
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Recommended Directory Layout
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui components
|
||||
│ │ │ ├── button.tsx
|
||||
│ │ │ ├── card.tsx
|
||||
│ │ │ ├── dialog.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── features/ # Feature-specific components
|
||||
│ │ │ ├── auth/
|
||||
│ │ │ ├── dashboard/
|
||||
│ │ │ └── ...
|
||||
│ │ └── layout/ # Layout components
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ └── Footer.tsx
|
||||
│ ├── lib/
|
||||
│ │ ├── utils.ts # Utility functions (cn, etc.)
|
||||
│ │ ├── api.ts # API client
|
||||
│ │ └── hooks/ # Custom React hooks
|
||||
│ ├── pages/ # Page components (if using routing)
|
||||
│ ├── styles/
|
||||
│ │ └── globals.css # Global styles and Tailwind imports
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── App.tsx # Main app component
|
||||
│ └── main.tsx # Entry point
|
||||
├── public/ # Static assets
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tailwind.config.js
|
||||
├── vite.config.ts
|
||||
└── components.json # shadcn/ui configuration
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### 1. Button Component (shadcn/ui style)
|
||||
|
||||
```typescript
|
||||
// src/components/ui/button.tsx
|
||||
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 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 hover:text-accent-foreground",
|
||||
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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
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 }
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
// Different variants
|
||||
<Button>Default</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
```
|
||||
|
||||
### 2. Card Component Pattern
|
||||
|
||||
```typescript
|
||||
// src/components/ui/card.tsx
|
||||
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-xl border bg-card text-card-foreground shadow",
|
||||
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("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 }
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
<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>
|
||||
```
|
||||
|
||||
### 3. Dialog/Modal Component
|
||||
|
||||
```typescript
|
||||
// src/components/ui/dialog.tsx (simplified shadcn/ui pattern)
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent }
|
||||
```
|
||||
|
||||
## Tailwind CSS Configuration
|
||||
|
||||
### Enhanced tailwind.config.js
|
||||
|
||||
```javascript
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
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")],
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Variables (globals.css)
|
||||
|
||||
```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: 221.2 83.2% 53.3%;
|
||||
--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: 221.2 83.2% 53.3%;
|
||||
--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: 217.2 91.2% 59.8%;
|
||||
--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: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### cn() - Class Name Utility
|
||||
|
||||
```typescript
|
||||
// src/lib/utils.ts
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**: Merge Tailwind classes without conflicts
|
||||
```typescript
|
||||
cn("px-2 py-1", "px-3") // Result: "py-1 px-3" (px-3 overrides px-2)
|
||||
```
|
||||
|
||||
## TypeScript Best Practices
|
||||
|
||||
### Component Props Typing
|
||||
|
||||
```typescript
|
||||
// Extend HTML attributes
|
||||
interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// Or use type
|
||||
type CustomButtonProps = {
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
```
|
||||
|
||||
### API Response Typing
|
||||
|
||||
```typescript
|
||||
// Define API response types
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
message: string
|
||||
status: 'success' | 'error'
|
||||
}
|
||||
|
||||
// Usage
|
||||
const fetchUser = async (id: string): Promise<ApiResponse<User>> => {
|
||||
const response = await fetch(`/api/users/${id}`)
|
||||
return response.json()
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Patterns
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
```typescript
|
||||
// Focus management
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Trap focus in modal
|
||||
import { useFocusTrap } from '@/lib/hooks/use-focus-trap'
|
||||
|
||||
function Modal({ children }: { children: React.ReactNode }) {
|
||||
const modalRef = useFocusTrap()
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal="true">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### ARIA Labels
|
||||
|
||||
```typescript
|
||||
// Screen reader support
|
||||
<button
|
||||
aria-label="Close dialog"
|
||||
aria-pressed={isPressed}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
// Form accessibility
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
aria-required="true"
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={hasError ? "email-error" : undefined}
|
||||
/>
|
||||
{hasError && <p id="email-error" role="alert">Invalid email</p>}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Code Splitting
|
||||
|
||||
```typescript
|
||||
// Lazy load routes
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Memoization
|
||||
|
||||
```typescript
|
||||
import { memo, useMemo, useCallback } from 'react'
|
||||
|
||||
// Memo component
|
||||
const ExpensiveComponent = memo(({ data }: { data: Data[] }) => {
|
||||
return <div>{/* Render data */}</div>
|
||||
})
|
||||
|
||||
// Memo computation
|
||||
function Component({ items }: { items: Item[] }) {
|
||||
const sortedItems = useMemo(
|
||||
() => items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[items]
|
||||
)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
console.log('Clicked')
|
||||
}, [])
|
||||
|
||||
return <List items={sortedItems} onClick={handleClick} />
|
||||
}
|
||||
```
|
||||
|
||||
## Animation with Framer Motion
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install framer-motion
|
||||
```
|
||||
|
||||
### Core Principles
|
||||
|
||||
**High-Impact Moments Over Random Motion**:
|
||||
- One well-orchestrated page load with staggered reveals > dozen random micro-animations
|
||||
- Focus on: page load, route transitions, major state changes
|
||||
- Use CSS for simple transitions, Framer Motion for complex choreography
|
||||
- Always respect `prefers-reduced-motion`
|
||||
|
||||
### Page Transitions
|
||||
|
||||
```typescript
|
||||
// app/layout.tsx or page wrapper
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
export default function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: [0.22, 1, 0.36, 1] // Custom ease (easeOutExpo)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Staggered List Animation
|
||||
|
||||
```typescript
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1 // Delay between each child animation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 }
|
||||
}
|
||||
|
||||
export function StaggeredList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<motion.ul
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-2"
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<motion.li
|
||||
key={i}
|
||||
variants={item}
|
||||
className="p-4 bg-card rounded-lg"
|
||||
>
|
||||
{item}
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Card Hover Effects
|
||||
|
||||
```typescript
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function AnimatedCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
className="rounded-xl border bg-card p-6 shadow-sm cursor-pointer"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Animations (Shared Layout)
|
||||
|
||||
```typescript
|
||||
import { motion, LayoutGroup } from 'framer-motion'
|
||||
|
||||
export function TabsWithAnimation({ tabs }: { tabs: Tab[] }) {
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className="flex gap-2">
|
||||
{tabs.map((tab, i) => (
|
||||
<motion.button
|
||||
key={i}
|
||||
onClick={() => setActiveTab(i)}
|
||||
className="relative px-4 py-2 rounded-md"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === i && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute inset-0 bg-primary rounded-md"
|
||||
style={{ zIndex: -1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll-Based Animations
|
||||
|
||||
```typescript
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { useRef } from 'react'
|
||||
|
||||
export function ParallaxSection() {
|
||||
const ref = useRef(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start end", "end start"]
|
||||
})
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], [0, -100])
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0])
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
ref={ref}
|
||||
style={{ y, opacity }}
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
>
|
||||
<h2 className="text-4xl font-bold">Parallax Content</h2>
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Modal / Dialog Animations
|
||||
|
||||
```typescript
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
export function AnimatedDialog({
|
||||
open,
|
||||
onClose,
|
||||
children
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/80 z-50"
|
||||
/>
|
||||
|
||||
{/* Dialog content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 bg-background p-6 rounded-lg shadow-lg max-w-md w-full"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Gesture Animations
|
||||
|
||||
```typescript
|
||||
import { motion, useDragControls } from 'framer-motion'
|
||||
|
||||
export function DraggableCard() {
|
||||
const controls = useDragControls()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
drag
|
||||
dragControls={controls}
|
||||
dragConstraints={{ left: 0, right: 300, top: 0, bottom: 300 }}
|
||||
dragElastic={0.1}
|
||||
whileDrag={{ scale: 1.05, cursor: "grabbing" }}
|
||||
className="w-32 h-32 bg-primary rounded-lg cursor-grab"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Swipe to dismiss
|
||||
export function SwipeableBanner({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
onDragEnd={(e, { offset, velocity }) => {
|
||||
if (Math.abs(offset.x) > 100 || Math.abs(velocity.x) > 500) {
|
||||
onDismiss()
|
||||
}
|
||||
}}
|
||||
className="p-4 bg-card border rounded-lg"
|
||||
>
|
||||
Swipe to dismiss
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Loading States with Animation
|
||||
|
||||
```typescript
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
|
||||
className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseLoader() {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.5, 1, 0.5]
|
||||
}}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2
|
||||
}}
|
||||
className="w-3 h-3 bg-primary rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Respecting Reduced Motion
|
||||
|
||||
```typescript
|
||||
import { motion, useReducedMotion } from 'framer-motion'
|
||||
|
||||
export function AccessibleAnimation({ children }: { children: React.ReactNode }) {
|
||||
const shouldReduceMotion = useReducedMotion()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 20 }}
|
||||
animate={shouldReduceMotion ? false : { opacity: 1, y: 0 }}
|
||||
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.5 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Best Practices
|
||||
|
||||
**What to Animate (GPU-Accelerated)**:
|
||||
- `opacity`
|
||||
- `transform` (translate, scale, rotate)
|
||||
- `filter` (blur, brightness)
|
||||
|
||||
**What to Avoid Animating**:
|
||||
- `width`, `height` (causes layout thrashing)
|
||||
- `top`, `left`, `margin`, `padding` (use `transform: translate` instead)
|
||||
- `color`, `background-color` (expensive, use sparingly)
|
||||
|
||||
**Optimization Tips**:
|
||||
```typescript
|
||||
// Use will-change for smoother animations
|
||||
<motion.div style={{ willChange: "transform" }}>
|
||||
|
||||
// Lazy load motion components
|
||||
import { motion, LazyMotion, domAnimation } from "framer-motion"
|
||||
|
||||
<LazyMotion features={domAnimation}>
|
||||
<motion.div />
|
||||
</LazyMotion>
|
||||
|
||||
// Reduce animation complexity on low-end devices
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
```
|
||||
|
||||
### When to Use Framer Motion vs CSS
|
||||
|
||||
**Use CSS Animations**:
|
||||
- Simple hover effects
|
||||
- Basic transitions
|
||||
- Static keyframe animations
|
||||
- Better performance for simple cases
|
||||
|
||||
**Use Framer Motion**:
|
||||
- Complex orchestration (staggered lists, sequences)
|
||||
- Gesture-based interactions (drag, swipe)
|
||||
- Layout animations (morphing between states)
|
||||
- Scroll-based animations
|
||||
- Dynamic animations based on state
|
||||
- Need for easier control and declarative API
|
||||
|
||||
## When to Apply
|
||||
|
||||
Use this skill when:
|
||||
- Building modern React applications with TypeScript
|
||||
- Implementing accessible, production-quality UI components
|
||||
- Using Tailwind CSS for styling
|
||||
- Integrating shadcn/ui component library
|
||||
- Setting up new projects with Vite + React
|
||||
- Creating reusable component libraries
|
||||
- Implementing design systems with consistent patterns
|
||||
- Building dashboards, admin panels, or web applications
|
||||
|
||||
This stack provides excellent developer experience, type safety, accessibility, and performance for modern web development.
|
||||
Reference in New Issue
Block a user