mirror of
https://github.com/delphi-ai/animate-skill.git
synced 2026-09-14 14:57:34 +08:00
Initial commit: animate skill for Claude Code
Animation patterns and best practices for Next.js/React based on Emil Kowalski's "Animations on the Web" course. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Animate Skill for Claude Code
|
||||
|
||||
Animation patterns and best practices for Next.js/React applications. Based on Emil Kowalski's "Animations on the Web" course.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/delphi-ai/animate-skill ~/.claude/skills/animate
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
- **SKILL.md** - Quick reference for easing, timing, and common patterns
|
||||
- **examples/** - 8 complete working examples (hover effects, toasts, modals, etc.)
|
||||
- **references/** - Detailed docs on CSS animations, Framer Motion, performance, accessibility
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, the skill triggers automatically when you ask Claude Code to implement animations, transitions, hover effects, modals, or any motion in React components.
|
||||
|
||||
You can also invoke it directly with `/animate`.
|
||||
|
||||
## Examples Included
|
||||
|
||||
| Example | Description |
|
||||
|---------|-------------|
|
||||
| `card-hover.tsx` | Slide-up description on hover |
|
||||
| `toast-stacking.tsx` | Animated toast notifications |
|
||||
| `text-reveal.tsx` | Staggered letter animation |
|
||||
| `shared-layout.tsx` | Framer Motion layoutId morph |
|
||||
| `animate-height.tsx` | Smooth height changes |
|
||||
| `multi-step-flow.tsx` | Directional step wizard |
|
||||
| `feedback-popover.tsx` | Button-to-popover expansion |
|
||||
| `app-store-card.tsx` | iOS-style card expansion |
|
||||
|
||||
## Dependencies
|
||||
|
||||
For Framer Motion examples:
|
||||
```bash
|
||||
pnpm add framer-motion react-use-measure usehooks-ts
|
||||
```
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: animate
|
||||
description: Animation patterns and best practices for Next.js/React applications. Use this skill when implementing animations, transitions, hover effects, page transitions, modals, or any motion in React components. Based on Emil Kowalski's "Animations on the Web" course.
|
||||
---
|
||||
|
||||
# Next.js Animations
|
||||
|
||||
## Overview
|
||||
|
||||
This skill provides comprehensive guidance for implementing smooth, performant, and accessible animations in Next.js and React applications. It covers CSS animations, Framer Motion, easing principles, and accessibility considerations.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Easing Cheat Sheet
|
||||
|
||||
| Animation Type | Easing | Duration |
|
||||
|----------------|--------|----------|
|
||||
| Element entering | `ease-out` | 200-300ms |
|
||||
| Element moving on screen | `ease-in-out` | 200-300ms |
|
||||
| Element exiting | `ease-in` | 150-200ms |
|
||||
| Hover effects | `ease` | 150ms |
|
||||
| Opacity only | `linear` | varies |
|
||||
|
||||
### CSS Custom Properties (Recommended)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--ease-out-quint: cubic-bezier(.23, 1, .32, 1);
|
||||
--ease-in-out-cubic: cubic-bezier(.645, .045, .355, 1);
|
||||
--ease-out-cubic: cubic-bezier(.33, 1, .68, 1);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Animation Patterns
|
||||
|
||||
### 1. Hover Lift Effect
|
||||
|
||||
```css
|
||||
.card {
|
||||
transition: transform 200ms var(--ease-out-quint),
|
||||
box-shadow 200ms var(--ease-out-quint);
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Button Press
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 100ms ease-out;
|
||||
}
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Fade In on Mount (Framer Motion)
|
||||
|
||||
```tsx
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: [.23, 1, .32, 1] }}
|
||||
>
|
||||
Content
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### 4. Modal with Exit Animation
|
||||
|
||||
```tsx
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
```
|
||||
|
||||
### 5. Tab Indicator (Shared Layout)
|
||||
|
||||
```tsx
|
||||
{tabs.map(tab => (
|
||||
<button key={tab} onClick={() => setActive(tab)} className="relative px-4 py-2">
|
||||
{tab}
|
||||
{active === tab && (
|
||||
<motion.div
|
||||
layoutId="tab-indicator"
|
||||
className="absolute inset-0 bg-blue-500 rounded -z-10"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
```
|
||||
|
||||
### 6. Staggered List Animation
|
||||
|
||||
```tsx
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
}
|
||||
|
||||
<motion.ul variants={container} initial="hidden" animate="visible">
|
||||
{items.map(i => <motion.li key={i} variants={item}>{i}</motion.li>)}
|
||||
</motion.ul>
|
||||
```
|
||||
|
||||
## Golden Rules
|
||||
|
||||
1. **Exits faster than enters**: Exit animations should be ~75% of enter duration
|
||||
2. **Only animate transform and opacity**: These are GPU-accelerated
|
||||
3. **200-300ms is the sweet spot**: Most animations should be in this range
|
||||
4. **Always respect prefers-reduced-motion**: See accessibility section in references
|
||||
5. **Use springs for interruptible animations**: Better UX when users interrupt
|
||||
|
||||
## Examples
|
||||
|
||||
Complete working examples from the course are in the `examples/` directory:
|
||||
|
||||
| Example | Description | Key Techniques |
|
||||
|---------|-------------|----------------|
|
||||
| `card-hover.tsx` | Slide-up description on hover | CSS transitions, transform, opacity |
|
||||
| `toast-stacking.tsx` | Animated toast notifications | CSS custom properties, data-* triggers |
|
||||
| `text-reveal.tsx` | Staggered letter animation | @keyframes, animation-delay, calc() |
|
||||
| `shared-layout.tsx` | Element position/size morph | Framer Motion layoutId |
|
||||
| `animate-height.tsx` | Smooth height changes | useMeasure, animate height |
|
||||
| `multi-step-flow.tsx` | Directional step wizard | AnimatePresence, custom variants |
|
||||
| `feedback-popover.tsx` | Button-to-popover expansion | Nested layoutId, form states |
|
||||
| `app-store-card.tsx` | iOS-style card expansion | Multiple layoutId elements |
|
||||
|
||||
To use an example, read it with: `Read examples/<name>.tsx`
|
||||
|
||||
## References
|
||||
|
||||
For detailed documentation, read the reference files:
|
||||
|
||||
- `references/easing-and-timing.md` - Easing functions, timing guidelines, spring configuration
|
||||
- `references/css-animations.md` - Transforms, transitions, keyframes, clip-path
|
||||
- `references/framer-motion.md` - Motion components, AnimatePresence, variants, layout animations, hooks
|
||||
- `references/performance-accessibility.md` - 60fps optimization, prefers-reduced-motion, accessibility
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Scenario | Recommended Approach |
|
||||
|----------|---------------------|
|
||||
| Simple hover effects | CSS transitions |
|
||||
| Enter/exit animations | Framer Motion + AnimatePresence |
|
||||
| Layout changes | Framer Motion `layout` prop |
|
||||
| Shared element transitions | Framer Motion `layoutId` |
|
||||
| Scroll-linked animations | Framer Motion `useScroll` |
|
||||
| Complex orchestrated animations | Framer Motion variants |
|
||||
| Drag interactions | Framer Motion drag gestures |
|
||||
| Performance-critical | CSS-only with transforms |
|
||||
|
||||
## Dependencies
|
||||
|
||||
For Framer Motion examples, install:
|
||||
```bash
|
||||
pnpm add framer-motion react-use-measure usehooks-ts
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Animate Height
|
||||
*
|
||||
* Smoothly animate height changes when content expands/collapses.
|
||||
* Uses react-use-measure to get the actual content height.
|
||||
*
|
||||
* Key techniques:
|
||||
* - useMeasure hook to get dynamic content dimensions
|
||||
* - Animate height property (normally discouraged, but acceptable here)
|
||||
* - Inner wrapper ref for measuring, outer wrapper for animating
|
||||
* - Works for any dynamic content changes
|
||||
*
|
||||
* Note: Install react-use-measure: pnpm add react-use-measure
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
// styles.css
|
||||
const styles = `
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.element {
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AnimateHeight() {
|
||||
const [showExtraContent, setShowExtraContent] = useState(false);
|
||||
const [ref, bounds] = useMeasure();
|
||||
|
||||
return (
|
||||
<div className="wrapper">
|
||||
<button onClick={() => setShowExtraContent((b) => !b)}>
|
||||
Toggle height
|
||||
</button>
|
||||
|
||||
{/* Outer div animates to measured height */}
|
||||
<motion.div
|
||||
className="element"
|
||||
animate={{ height: bounds.height }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Inner div is measured */}
|
||||
<div className="inner" ref={ref}>
|
||||
<h1>Expandable Drawer</h1>
|
||||
<p>
|
||||
This content can grow and shrink. The animation smoothly
|
||||
transitions between heights.
|
||||
</p>
|
||||
{showExtraContent && (
|
||||
<p>
|
||||
This extra content will change the height of the drawer.
|
||||
The animation handles any content changes automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* App Store Card Expansion
|
||||
*
|
||||
* iOS App Store-style card that expands to full screen with shared
|
||||
* layout animation. Multiple elements animate together.
|
||||
*
|
||||
* Key techniques:
|
||||
* - Multiple layoutId elements that animate together
|
||||
* - whileTap for press feedback
|
||||
* - Overlay with separate AnimatePresence
|
||||
* - useOnClickOutside and Escape key for dismiss
|
||||
* - borderRadius in style prop to prevent distortion
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useOnClickOutside } from "usehooks-ts";
|
||||
|
||||
type Card = {
|
||||
title: string;
|
||||
description: string;
|
||||
longDescription: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
// Card in grid view
|
||||
function Card({
|
||||
card,
|
||||
setActiveCard,
|
||||
}: {
|
||||
card: Card;
|
||||
setActiveCard: (card: Card | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layoutId={`card-${card.title}`}
|
||||
className="card"
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setActiveCard(card)}
|
||||
style={{ borderRadius: 20 }}
|
||||
>
|
||||
<motion.img
|
||||
layoutId={`image-${card.title}`}
|
||||
src={card.image}
|
||||
alt=""
|
||||
style={{ borderRadius: 20 }}
|
||||
draggable={false}
|
||||
/>
|
||||
<motion.div layoutId={`card-content-${card.title}`} className="card-content">
|
||||
<motion.h2 layoutId={`card-heading-${card.title}`}>
|
||||
{card.title}
|
||||
</motion.h2>
|
||||
<motion.p layoutId={`card-description-${card.title}`}>
|
||||
{card.description}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Hidden long description - will animate in when expanded */}
|
||||
<motion.div
|
||||
layoutId={`card-long-description-${card.title}`}
|
||||
style={{ position: "absolute", top: "100%", opacity: 0 }}
|
||||
>
|
||||
{card.longDescription}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded card view
|
||||
function ActiveCard({
|
||||
activeCard,
|
||||
setActiveCard,
|
||||
}: {
|
||||
activeCard: Card;
|
||||
setActiveCard: (card: Card | null) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useOnClickOutside(ref, () => setActiveCard(null));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layoutId={`card-${activeCard.title}`}
|
||||
className="card card-active"
|
||||
style={{ borderRadius: 0 }}
|
||||
>
|
||||
<motion.img
|
||||
layoutId={`image-${activeCard.title}`}
|
||||
src={activeCard.image}
|
||||
alt=""
|
||||
style={{ borderRadius: 0 }}
|
||||
/>
|
||||
<motion.button
|
||||
className="close-button"
|
||||
onClick={() => setActiveCard(null)}
|
||||
>
|
||||
✕
|
||||
</motion.button>
|
||||
<motion.div layoutId={`card-content-${activeCard.title}`} className="card-content">
|
||||
<motion.h2 layoutId={`card-heading-${activeCard.title}`}>
|
||||
{activeCard.title}
|
||||
</motion.h2>
|
||||
<motion.p layoutId={`card-description-${activeCard.title}`}>
|
||||
{activeCard.description}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Long description now visible */}
|
||||
<motion.div layoutId={`card-long-description-${activeCard.title}`}>
|
||||
{activeCard.longDescription}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppStoreCards() {
|
||||
const [activeCard, setActiveCard] = useState<Card | null>(null);
|
||||
|
||||
// Dismiss on Escape
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setActiveCard(null);
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="cards-wrapper">
|
||||
{CARDS.map((card) => (
|
||||
<Card key={card.title} card={card} setActiveCard={setActiveCard} />
|
||||
))}
|
||||
|
||||
{/* Overlay */}
|
||||
<AnimatePresence>
|
||||
{activeCard && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="overlay"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Expanded card */}
|
||||
<AnimatePresence>
|
||||
{activeCard && (
|
||||
<ActiveCard activeCard={activeCard} setActiveCard={setActiveCard} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CARDS: Card[] = [
|
||||
{
|
||||
title: "Game Title",
|
||||
description: "A brief description",
|
||||
longDescription: "Full description with more details about the game...",
|
||||
image: "/game-image.webp",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Card Hover Animation
|
||||
*
|
||||
* A card component where the description slides up from the bottom on hover.
|
||||
* Uses CSS transitions with transform and opacity for smooth animation.
|
||||
*
|
||||
* Key techniques:
|
||||
* - Transform translateY to slide content in/out
|
||||
* - Combined transition on transform and opacity
|
||||
* - :hover and :focus-visible for accessibility
|
||||
* - overflow: hidden on parent to clip content
|
||||
*/
|
||||
|
||||
// card-hover.css
|
||||
const styles = `
|
||||
.card-hover {
|
||||
width: 340px;
|
||||
height: 340px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
text-decoration: none;
|
||||
box-shadow: 0px 0px 0px 1px rgba(9, 9, 11, 0.08),
|
||||
0px 1px 2px -1px rgba(9, 9, 11, 0.08),
|
||||
0px 2px 4px 0px rgba(9, 9, 11, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-hover-description {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #fff;
|
||||
position: relative;
|
||||
background: #fafafa;
|
||||
margin: 6px;
|
||||
width: 100%;
|
||||
padding: 10px 14px 13px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.08),
|
||||
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Animation - hidden by default */
|
||||
transform: translateY(calc(100% + 8px));
|
||||
transition-duration: 350ms;
|
||||
transition-timing-function: ease;
|
||||
transition-property: transform, opacity;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Show on hover or keyboard focus */
|
||||
.card-hover:hover .card-hover-description,
|
||||
.card-hover:focus-visible .card-hover-description {
|
||||
transform: translateY(0%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-hover-title {
|
||||
color: #1b1b1d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-hover-subtitle {
|
||||
color: #717175;
|
||||
line-height: 1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function CardHover() {
|
||||
return (
|
||||
<a href="#" className="card-hover">
|
||||
<div className="card-hover-description">
|
||||
<h3 className="card-hover-title">Project name</h3>
|
||||
<p className="card-hover-subtitle">Project description</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Feedback Popover Animation
|
||||
*
|
||||
* A button that expands into a feedback form using shared layout animation.
|
||||
* Shows loading and success states with animated transitions.
|
||||
*
|
||||
* Key techniques:
|
||||
* - layoutId on button and popover for seamless expansion
|
||||
* - Nested layoutId elements (text placeholder)
|
||||
* - AnimatePresence for popover enter/exit
|
||||
* - Form state transitions (idle -> loading -> success)
|
||||
* - useOnClickOutside for dismiss behavior
|
||||
*
|
||||
* Note: Install usehooks-ts: pnpm add usehooks-ts
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useOnClickOutside } from "usehooks-ts";
|
||||
|
||||
export default function FeedbackPopover() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<"idle" | "loading" | "success">("idle");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
useOnClickOutside(ref, () => setOpen(false));
|
||||
|
||||
function submit() {
|
||||
setFormState("loading");
|
||||
setTimeout(() => setFormState("success"), 1500);
|
||||
setTimeout(() => setOpen(false), 3300);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter" && open && formState === "idle") {
|
||||
submit();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, formState]);
|
||||
|
||||
return (
|
||||
<div className="feedback-wrapper">
|
||||
{/* Button with layoutId - will animate into popover */}
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
setFormState("idle");
|
||||
setFeedback("");
|
||||
}}
|
||||
className="feedback-button"
|
||||
style={{ borderRadius: 8 }}
|
||||
layoutId="background"
|
||||
>
|
||||
<motion.span layoutId="placeholder">
|
||||
Feedback
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className="feedback-popover"
|
||||
style={{ borderRadius: 12 }}
|
||||
layoutId="background"
|
||||
>
|
||||
{/* Placeholder text animates with the layout */}
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="placeholder"
|
||||
layoutId="placeholder"
|
||||
style={{ opacity: feedback ? 0 : 0.5 }}
|
||||
>
|
||||
Feedback
|
||||
</motion.span>
|
||||
|
||||
{formState === "success" ? (
|
||||
<div className="success-wrapper">
|
||||
<h3>Feedback received!</h3>
|
||||
<p>Thanks for your feedback.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||
<textarea
|
||||
autoFocus
|
||||
placeholder="Feedback"
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
value={feedback}
|
||||
required
|
||||
/>
|
||||
<button type="submit">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={formState}
|
||||
initial={{ opacity: 0, y: -25 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 25 }}
|
||||
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
|
||||
>
|
||||
{formState === "loading" ? "Sending..." : "Send feedback"}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Multi-Step Flow Animation
|
||||
*
|
||||
* Animated wizard/stepper with directional slide transitions.
|
||||
* Steps slide in from the direction of navigation.
|
||||
*
|
||||
* Key techniques:
|
||||
* - AnimatePresence mode="popLayout" for exit animations
|
||||
* - custom prop for directional variants (slide left or right)
|
||||
* - useMeasure for animating container height
|
||||
* - useReducedMotion for accessibility
|
||||
* - MotionConfig for shared transition settings
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
MotionConfig,
|
||||
motion,
|
||||
useReducedMotion,
|
||||
} from "framer-motion";
|
||||
import useMeasure from "react-use-measure";
|
||||
|
||||
// Directional slide variants
|
||||
const variants = {
|
||||
initial: (direction: number) => ({
|
||||
x: `${110 * direction}%`,
|
||||
opacity: 0,
|
||||
}),
|
||||
active: {
|
||||
x: "0%",
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: `${-110 * direction}%`,
|
||||
opacity: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
// Reduced motion alternative - just fade
|
||||
const reducedMotionVariants = {
|
||||
initial: { opacity: 0 },
|
||||
active: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
};
|
||||
|
||||
export default function MultiStepComponent() {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [direction, setDirection] = useState<number>(0);
|
||||
const [ref, bounds] = useMeasure();
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<>
|
||||
<h2>Step One</h2>
|
||||
<p>First step content goes here.</p>
|
||||
</>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<>
|
||||
<h2>Step Two</h2>
|
||||
<p>Second step with different content length.</p>
|
||||
<p>Extra paragraph to show height animation.</p>
|
||||
</>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<>
|
||||
<h2>Step Three</h2>
|
||||
<p>Final step - you made it!</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
return (
|
||||
<MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0 }}>
|
||||
{/* Container animates height based on content */}
|
||||
<motion.div
|
||||
className="multi-step-wrapper"
|
||||
animate={reducedMotion ? {} : { height: bounds.height }}
|
||||
>
|
||||
<div className="multi-step-inner" ref={ref}>
|
||||
{/* AnimatePresence handles exit animations */}
|
||||
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
variants={reducedMotion ? reducedMotionVariants : variants}
|
||||
initial="initial"
|
||||
animate="active"
|
||||
exit="exit"
|
||||
custom={direction}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Buttons use layout animation to stay in place */}
|
||||
<motion.div className="actions" layout={!reducedMotion}>
|
||||
<button
|
||||
disabled={currentStep === 0}
|
||||
onClick={() => {
|
||||
setCurrentStep((prev) => prev - 1);
|
||||
setDirection(-1); // Slide from left
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
disabled={currentStep === 2}
|
||||
onClick={() => {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setDirection(1); // Slide from right
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</MotionConfig>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared Layout Animation
|
||||
*
|
||||
* Demonstrates Framer Motion's layoutId for seamless transitions
|
||||
* between two different elements/positions.
|
||||
*
|
||||
* Key techniques:
|
||||
* - layoutId creates a shared identity between elements
|
||||
* - Motion automatically animates position and size changes
|
||||
* - style={{ borderRadius }} prevents border radius distortion during animation
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
|
||||
// styles.css
|
||||
const styles = `
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.element {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.second-element {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function SharedLayoutExample() {
|
||||
const [showSecond, setShowSecond] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="wrapper">
|
||||
<button onClick={() => setShowSecond((s) => !s)}>
|
||||
Animate
|
||||
</button>
|
||||
|
||||
{/* Same layoutId = smooth transition between the two */}
|
||||
{showSecond ? (
|
||||
<motion.div
|
||||
layoutId="rectangle"
|
||||
className="second-element"
|
||||
style={{ borderRadius: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
layoutId="rectangle"
|
||||
className="element"
|
||||
style={{ borderRadius: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Text Reveal Animation
|
||||
*
|
||||
* Staggered letter-by-letter text reveal animation using CSS keyframes.
|
||||
* Each letter animates in sequence with a delay based on its index.
|
||||
*
|
||||
* Key techniques:
|
||||
* - Split text into individual spans
|
||||
* - CSS custom property (--index) for stagger delay
|
||||
* - @keyframes for the reveal animation
|
||||
* - animation-fill-mode: backwards to hide before animation
|
||||
* - cubic-bezier for smooth easing
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// text-reveal.css
|
||||
const styles = `
|
||||
.h1 {
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.h1 span {
|
||||
display: inline-block;
|
||||
animation: reveal 1.3s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
animation-fill-mode: backwards;
|
||||
animation-delay: calc(var(--index) * 0.03s);
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const WORD = "Animations";
|
||||
|
||||
export default function TextReveal() {
|
||||
const [reset, setReset] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Key change forces re-render and replays animation */}
|
||||
<div key={reset}>
|
||||
<h1 className="h1">
|
||||
{WORD.split("").map((char, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{ "--index": index } as React.CSSProperties}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</h1>
|
||||
</div>
|
||||
<button onClick={() => setReset(reset + 1)}>
|
||||
Replay animation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Toast Stacking Animation
|
||||
*
|
||||
* Animated toast notifications that stack vertically with smooth transitions.
|
||||
* Uses CSS custom properties (--index) for dynamic positioning.
|
||||
*
|
||||
* Key techniques:
|
||||
* - CSS custom properties for dynamic values
|
||||
* - data-* attributes to trigger CSS transitions
|
||||
* - useEffect to trigger mount animation
|
||||
* - calc() for computing stack positions
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// toast-animation.css
|
||||
const styles = `
|
||||
.toast {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px 13px;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.08),
|
||||
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
|
||||
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Animation - starts hidden below */
|
||||
transition-property: transform, opacity;
|
||||
transition-duration: 350ms;
|
||||
transition-timing-function: ease;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Animate to stacked position when mounted */
|
||||
.toast[data-mounted="true"] {
|
||||
transform: translateY(calc(var(--index) * (100% + var(--gap)) * -1));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toaster {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
width: 356px;
|
||||
transform: translateX(-50%);
|
||||
--gap: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Toaster() {
|
||||
const [toasts, setToasts] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="toast-wrapper">
|
||||
<div className="toaster">
|
||||
{Array.from({ length: toasts }).map((_, i) => (
|
||||
<Toast key={i} index={toasts - (i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setToasts(toasts + 1)}>
|
||||
Add toast
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toast({ index }: { index: number }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="toast"
|
||||
style={{ "--index": index } as React.CSSProperties}
|
||||
data-mounted={mounted}
|
||||
>
|
||||
<span className="title">Event Created</span>
|
||||
<span className="description">Monday, January 3rd at 6:00pm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# CSS Animations Reference
|
||||
|
||||
## Transforms
|
||||
|
||||
Transforms change an element's position, size, or rotation without affecting layout. They are GPU-accelerated and performant.
|
||||
|
||||
### Transform Functions
|
||||
|
||||
```css
|
||||
/* Translation (movement) */
|
||||
transform: translateX(10px);
|
||||
transform: translateY(-20px);
|
||||
transform: translate(10px, -20px); /* X, Y */
|
||||
transform: translate3d(10px, 20px, 30px); /* X, Y, Z */
|
||||
|
||||
/* Scale */
|
||||
transform: scale(1.1); /* Uniform scale */
|
||||
transform: scaleX(0.5);
|
||||
transform: scaleY(1.5);
|
||||
transform: scale(0.5, 1.5); /* X, Y */
|
||||
|
||||
/* Rotation */
|
||||
transform: rotate(45deg);
|
||||
transform: rotateX(45deg); /* 3D - around X axis */
|
||||
transform: rotateY(45deg); /* 3D - around Y axis */
|
||||
transform: rotateZ(45deg); /* Same as rotate() */
|
||||
|
||||
/* Combining transforms */
|
||||
transform: translateY(-10px) scale(1.05) rotate(5deg);
|
||||
```
|
||||
|
||||
### Transform Origin
|
||||
|
||||
Controls the point around which transforms occur:
|
||||
|
||||
```css
|
||||
transform-origin: center; /* Default */
|
||||
transform-origin: top left;
|
||||
transform-origin: 50% 100%; /* Center bottom */
|
||||
transform-origin: 0 0; /* Top left corner */
|
||||
```
|
||||
|
||||
### 3D Transforms
|
||||
|
||||
```css
|
||||
/* Enable 3D space on parent */
|
||||
.parent {
|
||||
perspective: 1000px; /* Distance from viewer */
|
||||
perspective-origin: center;
|
||||
}
|
||||
|
||||
/* 3D transforms on children */
|
||||
.child {
|
||||
transform: rotateY(45deg);
|
||||
transform-style: preserve-3d; /* Maintain 3D for nested elements */
|
||||
backface-visibility: hidden; /* Hide back of element */
|
||||
}
|
||||
```
|
||||
|
||||
## Transitions
|
||||
|
||||
Transitions animate property changes over time.
|
||||
|
||||
### Syntax
|
||||
|
||||
```css
|
||||
/* Individual properties */
|
||||
transition-property: transform, opacity;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: ease-out;
|
||||
transition-delay: 0ms;
|
||||
|
||||
/* Shorthand */
|
||||
transition: transform 200ms ease-out, opacity 150ms ease-out 50ms;
|
||||
/* property duration timing property duration timing delay */
|
||||
```
|
||||
|
||||
### Common Transition Patterns
|
||||
|
||||
```css
|
||||
/* Hover lift effect */
|
||||
.card {
|
||||
transition: transform 200ms ease-out, box-shadow 200ms ease-out;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Button press effect */
|
||||
.button {
|
||||
transition: transform 100ms ease-out;
|
||||
}
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Fade in/out */
|
||||
.element {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
.element.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
### Performance-Safe Properties
|
||||
|
||||
These properties are GPU-accelerated and won't cause layout recalculation:
|
||||
- `transform`
|
||||
- `opacity`
|
||||
|
||||
Avoid animating (cause layout/paint):
|
||||
- `width`, `height`
|
||||
- `top`, `left`, `right`, `bottom`
|
||||
- `margin`, `padding`
|
||||
- `border-width`
|
||||
|
||||
## Keyframe Animations
|
||||
|
||||
For complex, multi-step animations.
|
||||
|
||||
### Basic Syntax
|
||||
|
||||
```css
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.element {
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
}
|
||||
```
|
||||
|
||||
### Animation Properties
|
||||
|
||||
```css
|
||||
animation-name: fadeIn;
|
||||
animation-duration: 300ms;
|
||||
animation-timing-function: ease-out;
|
||||
animation-delay: 0ms;
|
||||
animation-iteration-count: 1; /* or 'infinite' */
|
||||
animation-direction: normal; /* or 'reverse', 'alternate' */
|
||||
animation-fill-mode: forwards; /* 'none', 'forwards', 'backwards', 'both' */
|
||||
animation-play-state: running; /* or 'paused' */
|
||||
|
||||
/* Shorthand */
|
||||
animation: fadeIn 300ms ease-out 0ms 1 normal forwards;
|
||||
```
|
||||
|
||||
### Fill Modes Explained
|
||||
|
||||
- `none`: No styles applied before/after animation
|
||||
- `forwards`: Retains final keyframe styles after animation
|
||||
- `backwards`: Applies initial keyframe styles during delay
|
||||
- `both`: Applies both forwards and backwards
|
||||
|
||||
## Clip-Path Animations
|
||||
|
||||
Clip-path creates masked areas that can be animated.
|
||||
|
||||
### Common Shapes
|
||||
|
||||
```css
|
||||
/* Circle */
|
||||
clip-path: circle(50% at center);
|
||||
clip-path: circle(0% at center); /* Hidden */
|
||||
|
||||
/* Ellipse */
|
||||
clip-path: ellipse(50% 30% at center);
|
||||
|
||||
/* Inset (rectangle) */
|
||||
clip-path: inset(0); /* Full visibility */
|
||||
clip-path: inset(50%); /* Hidden (collapsed to center) */
|
||||
clip-path: inset(0 50% 0 0); /* Right half hidden */
|
||||
/* inset(top right bottom left) */
|
||||
|
||||
/* Polygon */
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); /* Rectangle */
|
||||
clip-path: polygon(50% 0, 100% 100%, 0 100%); /* Triangle */
|
||||
```
|
||||
|
||||
### Clip-Path Animation Example (Image Comparison Slider)
|
||||
|
||||
```css
|
||||
.before-image {
|
||||
clip-path: inset(0 50% 0 0); /* Show left half */
|
||||
transition: clip-path 0ms; /* Instant update */
|
||||
}
|
||||
|
||||
/* JavaScript updates the clip-path based on slider position */
|
||||
```
|
||||
|
||||
### Tab Indicator with Clip-Path
|
||||
|
||||
```tsx
|
||||
// Active tab background slides using clip-path
|
||||
<div className="relative">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActive(index)}
|
||||
className="relative z-10 px-4 py-2"
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className="absolute inset-0 bg-blue-500 rounded transition-all duration-200"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - (activeIndex + 1) * (100 / tabs.length)}% 0 ${activeIndex * (100 / tabs.length)}% round 8px)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# Easing and Timing Reference
|
||||
|
||||
## Core Principles
|
||||
|
||||
Animations should feel natural and purposeful. The goal is to make interfaces feel responsive without drawing attention to the animation itself.
|
||||
|
||||
## Easing Functions
|
||||
|
||||
### When to Use Each Easing Type
|
||||
|
||||
| Easing | Use Case | Timing |
|
||||
|--------|----------|--------|
|
||||
| `ease-out` | Elements entering the screen | 200-300ms |
|
||||
| `ease-in-out` | Elements moving on screen | 200-300ms |
|
||||
| `ease-in` | Elements exiting the screen | 150-200ms |
|
||||
| `ease` | Hover effects | 150ms |
|
||||
| `linear` | Opacity changes, progress bars | varies |
|
||||
|
||||
### Custom Easing Variables
|
||||
|
||||
Define these CSS custom properties for consistent animations:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Recommended easing curves */
|
||||
--ease-out-quint: cubic-bezier(.23, 1, .32, 1);
|
||||
--ease-in-out-cubic: cubic-bezier(.645, .045, .355, 1);
|
||||
--ease-out-cubic: cubic-bezier(.33, 1, .68, 1);
|
||||
--ease-in-cubic: cubic-bezier(.32, 0, .67, 0);
|
||||
|
||||
/* Spring-like feel */
|
||||
--ease-out-back: cubic-bezier(.34, 1.56, .64, 1);
|
||||
}
|
||||
```
|
||||
|
||||
### Cubic Bezier Explained
|
||||
|
||||
`cubic-bezier(x1, y1, x2, y2)` defines a curve with two control points:
|
||||
- `(x1, y1)` - First control point (influences start of animation)
|
||||
- `(x2, y2)` - Second control point (influences end of animation)
|
||||
|
||||
Values > 1 create overshoot effects (like `ease-out-back`).
|
||||
|
||||
## Timing Guidelines
|
||||
|
||||
### Duration Sweet Spots
|
||||
|
||||
- **General animations**: 200-300ms (the sweet spot)
|
||||
- **Hover effects**: 150ms
|
||||
- **Modal enter**: 200ms
|
||||
- **Modal exit**: 150ms (exits should be faster than enters)
|
||||
- **Page transitions**: 300-400ms
|
||||
- **Micro-interactions**: 100-150ms
|
||||
|
||||
### Rules of Thumb
|
||||
|
||||
1. **Exits faster than enters**: Exit animations should be ~75% of enter duration
|
||||
2. **Smaller elements = faster animations**: Scale duration with element size
|
||||
3. **User-initiated = faster response**: Direct actions should feel immediate
|
||||
4. **System-initiated = can be slower**: Background transitions can take longer
|
||||
|
||||
## Spring Animations
|
||||
|
||||
Springs are excellent for interruptible animations and natural-feeling motion.
|
||||
|
||||
### Spring Parameters
|
||||
|
||||
| Parameter | Effect |
|
||||
|-----------|--------|
|
||||
| `mass` | Weight of the object (higher = slower, more momentum) |
|
||||
| `tension` (stiffness) | Spring tightness (higher = faster, snappier) |
|
||||
| `friction` (damping) | Resistance (higher = less oscillation) |
|
||||
| `velocity` | Initial speed |
|
||||
|
||||
### Framer Motion Spring Config
|
||||
|
||||
```tsx
|
||||
// Snappy spring (UI elements)
|
||||
const snappy = { type: "spring", stiffness: 400, damping: 30 }
|
||||
|
||||
// Gentle spring (larger elements)
|
||||
const gentle = { type: "spring", stiffness: 200, damping: 20 }
|
||||
|
||||
// Bouncy spring (playful interactions)
|
||||
const bouncy = { type: "spring", stiffness: 300, damping: 10 }
|
||||
```
|
||||
|
||||
### Duration-Based Springs (Framer Motion)
|
||||
|
||||
```tsx
|
||||
// Using duration instead of physics
|
||||
const durationSpring = {
|
||||
type: "spring",
|
||||
duration: 0.3,
|
||||
bounce: 0.2 // 0 = no bounce, 1 = max bounce
|
||||
}
|
||||
```
|
||||
|
||||
## When NOT to Animate
|
||||
|
||||
- Loading states that block interaction
|
||||
- Error messages that need immediate attention
|
||||
- Accessibility: respect `prefers-reduced-motion`
|
||||
- When animation adds no value to the experience
|
||||
- Repeated actions the user performs frequently
|
||||
@@ -0,0 +1,356 @@
|
||||
# Framer Motion Reference
|
||||
|
||||
Framer Motion is the recommended animation library for React/Next.js applications.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install framer-motion
|
||||
# or
|
||||
pnpm add framer-motion
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Motion Components
|
||||
|
||||
```tsx
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
// Any HTML element can become animated
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
>
|
||||
Content
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### Common Props
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `initial` | Starting state (or `false` to disable) |
|
||||
| `animate` | Target state |
|
||||
| `exit` | Exit state (requires AnimatePresence) |
|
||||
| `transition` | Animation configuration |
|
||||
| `whileHover` | State while hovered |
|
||||
| `whileTap` | State while pressed |
|
||||
| `whileFocus` | State while focused |
|
||||
| `whileInView` | State while in viewport |
|
||||
|
||||
## AnimatePresence
|
||||
|
||||
Required for exit animations. Wraps elements that may be removed from DOM.
|
||||
|
||||
```tsx
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
function Modal({ isOpen, onClose, children }) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### AnimatePresence Props
|
||||
|
||||
```tsx
|
||||
<AnimatePresence
|
||||
mode="wait" // 'sync' | 'wait' | 'popLayout'
|
||||
initial={false} // Disable initial animation on mount
|
||||
onExitComplete={() => {}} // Callback when all exits complete
|
||||
>
|
||||
```
|
||||
|
||||
- `mode="sync"` (default): Enter and exit happen simultaneously
|
||||
- `mode="wait"`: Wait for exit to complete before enter
|
||||
- `mode="popLayout"`: Remove exiting elements from layout flow
|
||||
|
||||
## Variants
|
||||
|
||||
Variants define animation states that can be orchestrated across parent/children.
|
||||
|
||||
```tsx
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1, // Delay between each child
|
||||
delayChildren: 0.2, // Initial delay before children start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
}
|
||||
|
||||
function List({ items }) {
|
||||
return (
|
||||
<motion.ul
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{items.map(item => (
|
||||
<motion.li key={item.id} variants={itemVariants}>
|
||||
{item.name}
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Layout Animations
|
||||
|
||||
Animate layout changes automatically with the `layout` prop.
|
||||
|
||||
```tsx
|
||||
// Element animates when its position/size changes
|
||||
<motion.div layout>
|
||||
{isExpanded ? "Expanded content here" : "Collapsed"}
|
||||
</motion.div>
|
||||
|
||||
// Layout types
|
||||
<motion.div layout> // Animate position and size
|
||||
<motion.div layout="position"> // Only animate position
|
||||
<motion.div layout="size"> // Only animate size
|
||||
```
|
||||
|
||||
### Shared Layout Animations (layoutId)
|
||||
|
||||
Elements with the same `layoutId` animate between each other.
|
||||
|
||||
```tsx
|
||||
function Tabs({ tabs, activeTab, setActiveTab }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="relative px-4 py-2"
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="tab-indicator"
|
||||
className="absolute inset-0 bg-blue-500 rounded -z-10"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### LayoutGroup
|
||||
|
||||
Group layout animations to prevent conflicts between independent animations.
|
||||
|
||||
```tsx
|
||||
import { LayoutGroup } from "framer-motion"
|
||||
|
||||
<LayoutGroup>
|
||||
<Tabs /> {/* These layout animations */}
|
||||
</LayoutGroup>
|
||||
<LayoutGroup>
|
||||
<OtherTabs /> {/* Won't conflict with these */}
|
||||
</LayoutGroup>
|
||||
```
|
||||
|
||||
## Gestures
|
||||
|
||||
### Drag
|
||||
|
||||
```tsx
|
||||
<motion.div
|
||||
drag // Enable both axes
|
||||
drag="x" // Horizontal only
|
||||
drag="y" // Vertical only
|
||||
dragConstraints={{ left: 0, right: 300, top: 0, bottom: 200 }}
|
||||
dragElastic={0.2} // Elasticity outside constraints (0-1)
|
||||
dragMomentum={true} // Continue after release
|
||||
dragTransition={{ bounceStiffness: 600, bounceDamping: 20 }}
|
||||
onDragStart={(event, info) => {}}
|
||||
onDrag={(event, info) => {}}
|
||||
onDragEnd={(event, info) => {}}
|
||||
>
|
||||
Drag me
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
### Drag Info Object
|
||||
|
||||
```tsx
|
||||
onDrag={(event, info) => {
|
||||
info.point // { x, y } current position
|
||||
info.delta // { x, y } change since last frame
|
||||
info.offset // { x, y } offset from drag start
|
||||
info.velocity // { x, y } current velocity
|
||||
}}
|
||||
```
|
||||
|
||||
## Motion Values & Hooks
|
||||
|
||||
### useMotionValue
|
||||
|
||||
Create a value that updates without re-rendering.
|
||||
|
||||
```tsx
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion"
|
||||
|
||||
function Component() {
|
||||
const x = useMotionValue(0)
|
||||
|
||||
// Transform x position to opacity
|
||||
const opacity = useTransform(x, [-100, 0, 100], [0, 1, 0])
|
||||
|
||||
// Transform x to rotation
|
||||
const rotate = useTransform(x, [-100, 100], [-10, 10])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
drag="x"
|
||||
style={{ x, opacity, rotate }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useSpring
|
||||
|
||||
Create a spring-animated motion value.
|
||||
|
||||
```tsx
|
||||
import { useSpring, useMotionValue } from "framer-motion"
|
||||
|
||||
function Component() {
|
||||
const x = useMotionValue(0)
|
||||
const springX = useSpring(x, { stiffness: 300, damping: 30 })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseMove={(e) => x.set(e.clientX)}
|
||||
style={{ x: springX }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useScroll
|
||||
|
||||
Track scroll progress.
|
||||
|
||||
```tsx
|
||||
import { useScroll, useTransform, motion } from "framer-motion"
|
||||
|
||||
function ProgressBar() {
|
||||
const { scrollYProgress } = useScroll()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-blue-500 origin-left"
|
||||
style={{ scaleX: scrollYProgress }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll within a container
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ["start end", "end start"] // When to start/end tracking
|
||||
})
|
||||
```
|
||||
|
||||
### useInView
|
||||
|
||||
Detect when element enters viewport.
|
||||
|
||||
```tsx
|
||||
import { useInView } from "framer-motion"
|
||||
|
||||
function Component() {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, {
|
||||
once: true, // Only trigger once
|
||||
margin: "-100px" // Trigger 100px before entering
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Transition Options
|
||||
|
||||
```tsx
|
||||
const transition = {
|
||||
// Timing
|
||||
duration: 0.3,
|
||||
delay: 0.1,
|
||||
|
||||
// Easing
|
||||
ease: "easeOut", // or array: [0.23, 1, 0.32, 1]
|
||||
|
||||
// Spring physics
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
|
||||
// Or duration-based spring
|
||||
type: "spring",
|
||||
duration: 0.3,
|
||||
bounce: 0.2,
|
||||
|
||||
// Per-property transitions
|
||||
opacity: { duration: 0.2 },
|
||||
x: { type: "spring", stiffness: 300 },
|
||||
|
||||
// Repeat
|
||||
repeat: Infinity,
|
||||
repeatType: "reverse", // "loop" | "reverse" | "mirror"
|
||||
repeatDelay: 0.5,
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Step Animations (Keyframes)
|
||||
|
||||
```tsx
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 100, 0], // Move right then back
|
||||
opacity: [0, 1, 1, 0], // Fade in, stay, fade out
|
||||
scale: [1, 1.2, 1], // Grow then shrink
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
times: [0, 0.5, 1], // When each keyframe occurs (0-1)
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,185 @@
|
||||
# Performance & Accessibility Reference
|
||||
|
||||
## Performance
|
||||
|
||||
### Target: 60 FPS
|
||||
|
||||
Animations should run at 60 frames per second (16.67ms per frame) for smooth motion.
|
||||
|
||||
### GPU-Accelerated Properties
|
||||
|
||||
Only these properties are hardware-accelerated and won't cause layout recalculation:
|
||||
|
||||
| Property | Notes |
|
||||
|----------|-------|
|
||||
| `transform` | translate, scale, rotate |
|
||||
| `opacity` | Fade effects |
|
||||
|
||||
### Properties to Avoid Animating
|
||||
|
||||
These trigger layout recalculation and are expensive:
|
||||
|
||||
- `width`, `height`
|
||||
- `top`, `left`, `right`, `bottom`
|
||||
- `margin`, `padding`
|
||||
- `border-width`
|
||||
- `font-size`
|
||||
|
||||
**Instead**: Use `transform: scale()` instead of width/height, `transform: translate()` instead of top/left.
|
||||
|
||||
### will-change
|
||||
|
||||
Hints to the browser that an element will animate:
|
||||
|
||||
```css
|
||||
.will-animate {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
**Caution**: Use sparingly. Apply only to elements that actually animate, and remove after animation completes if possible. Overuse can hurt performance.
|
||||
|
||||
### Hardware Acceleration Hack
|
||||
|
||||
Force GPU layer creation (use sparingly):
|
||||
|
||||
```css
|
||||
.force-gpu {
|
||||
transform: translateZ(0);
|
||||
/* or */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
### Monitoring Performance
|
||||
|
||||
1. **Chrome DevTools**: Performance tab → Record during animation
|
||||
2. **Rendering tab**: Enable "FPS meter" and "Paint flashing"
|
||||
3. **Layers panel**: Check layer composition
|
||||
|
||||
### Common Performance Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Janky scrolling | Use `transform` instead of `top/left` |
|
||||
| Slow animations | Reduce number of animated elements |
|
||||
| Memory spikes | Avoid creating new objects during animation |
|
||||
| Layout thrashing | Batch DOM reads/writes |
|
||||
|
||||
## Accessibility
|
||||
|
||||
### prefers-reduced-motion
|
||||
|
||||
Users can indicate they prefer reduced motion in their system settings. Always respect this preference.
|
||||
|
||||
#### CSS Implementation
|
||||
|
||||
```css
|
||||
/* Full animation by default */
|
||||
.element {
|
||||
transition: transform 300ms ease-out;
|
||||
}
|
||||
|
||||
/* Reduced or no motion for users who prefer it */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.element {
|
||||
transition: none;
|
||||
/* or minimal transition */
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### React/Framer Motion Implementation
|
||||
|
||||
```tsx
|
||||
import { useReducedMotion } from "framer-motion"
|
||||
|
||||
function AnimatedComponent() {
|
||||
const shouldReduceMotion = useReducedMotion()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Global Configuration with MotionConfig
|
||||
|
||||
```tsx
|
||||
import { MotionConfig, useReducedMotion } from "framer-motion"
|
||||
|
||||
function App() {
|
||||
const shouldReduceMotion = useReducedMotion()
|
||||
|
||||
return (
|
||||
<MotionConfig reducedMotion={shouldReduceMotion ? "always" : "never"}>
|
||||
{/* All motion components inside respect this setting */}
|
||||
<YourApp />
|
||||
</MotionConfig>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### MotionConfig Options
|
||||
|
||||
| Value | Effect |
|
||||
|-------|--------|
|
||||
| `"never"` | Normal animations (default) |
|
||||
| `"always"` | Skip all animations |
|
||||
| `"user"` | Respect system `prefers-reduced-motion` setting |
|
||||
|
||||
### Accessible Animation Guidelines
|
||||
|
||||
1. **Provide alternatives**: Ensure content is accessible without animation
|
||||
2. **Avoid flashing**: No content flashing more than 3 times per second
|
||||
3. **Don't block interaction**: Animations shouldn't prevent users from interacting
|
||||
4. **Keep it brief**: Long animations can be frustrating
|
||||
5. **Purposeful motion**: Only animate when it adds value
|
||||
6. **Pause controls**: For continuous animations, provide pause/stop
|
||||
|
||||
### Focus Management with Animations
|
||||
|
||||
```tsx
|
||||
// Ensure focus moves appropriately with animated elements
|
||||
function Modal({ isOpen }) {
|
||||
const modalRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Wait for enter animation before focusing
|
||||
const timer = setTimeout(() => {
|
||||
modalRef.current?.focus()
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
Modal content
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Reduced Motion
|
||||
|
||||
1. **macOS**: System Preferences → Accessibility → Display → Reduce motion
|
||||
2. **Windows**: Settings → Ease of Access → Display → Show animations
|
||||
3. **iOS**: Settings → Accessibility → Motion → Reduce Motion
|
||||
4. **Chrome DevTools**: Rendering tab → Emulate CSS media feature `prefers-reduced-motion`
|
||||
Reference in New Issue
Block a user