mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-13 11:45:56 +00:00
feat: refine gsap-framer-scroll-animation skill and references (#1284)
* feat: refine gsap-framer-scroll-animation skill and references * fix: address review comments for gsap-framer-scroll-animation skill
This commit is contained in:
147
skills/gsap-framer-scroll-animation/SKILL.md
Normal file
147
skills/gsap-framer-scroll-animation/SKILL.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: gsap-framer-scroll-animation
|
||||
description: >-
|
||||
Use this skill whenever the user wants to build scroll animations, scroll effects,
|
||||
parallax, scroll-triggered reveals, pinned sections, horizontal scroll, text animations,
|
||||
or any motion tied to scroll position — in vanilla JS, React, or Next.js.
|
||||
Covers GSAP ScrollTrigger (pinning, scrubbing, snapping, timelines, horizontal scroll,
|
||||
ScrollSmoother, matchMedia) and Framer Motion / Motion v12 (useScroll, useTransform,
|
||||
useSpring, whileInView, variants). Use this skill even if the user just says
|
||||
"animate on scroll", "fade in as I scroll", "make it scroll like Apple",
|
||||
"parallax effect", "sticky section", "scroll progress bar", or "entrance animation".
|
||||
Also triggers for Copilot prompt patterns for GSAP or Framer Motion code generation.
|
||||
Pairs with the premium-frontend-ui skill for creative philosophy and design-level polish.
|
||||
---
|
||||
|
||||
# GSAP & Framer Motion — Scroll Animations Skill
|
||||
|
||||
Production-grade scroll animations with GitHub Copilot prompts, ready-to-use code recipes, and deep API references.
|
||||
|
||||
> **Design Companion:** This skill provides the *technical implementation* for scroll-driven motion.
|
||||
> For the *creative philosophy*, design principles, and premium aesthetics that should guide **how**
|
||||
> and **when** to animate, always cross-reference the **premium-frontend-ui** skill.
|
||||
> Together they form a complete approach: premium-frontend-ui decides the **what** and **why**;
|
||||
> this skill delivers the **how**.
|
||||
|
||||
## Quick Library Selector
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Vanilla JS, Webflow, Vue | **GSAP** |
|
||||
| Pinning, horizontal scroll, complex timelines | **GSAP** |
|
||||
| React / Next.js, declarative style | **Framer Motion** |
|
||||
| whileInView entrance animations | **Framer Motion** |
|
||||
| Both in same Next.js app | See notes in references |
|
||||
|
||||
Read the relevant reference file for full recipes and Copilot prompts:
|
||||
|
||||
- **GSAP** → `references/gsap.md` — ScrollTrigger API, all recipes, React integration
|
||||
- **Framer Motion** → `references/framer.md` — useScroll, useTransform, all recipes
|
||||
|
||||
## Setup (Always Do First)
|
||||
|
||||
### GSAP
|
||||
```bash
|
||||
npm install gsap
|
||||
```
|
||||
```js
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
gsap.registerPlugin(ScrollTrigger); // MUST call before any ScrollTrigger usage
|
||||
```
|
||||
|
||||
### Framer Motion (Motion v12, 2025)
|
||||
```bash
|
||||
npm install motion # new package name since mid-2025
|
||||
# or: npm install framer-motion — still works, same API
|
||||
```
|
||||
```js
|
||||
import { motion, useScroll, useTransform, useSpring } from 'motion/react';
|
||||
// legacy: import { motion } from 'framer-motion' — also valid
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Interpret the user's intent to identify if GSAP or Framer Motion is the best fit.
|
||||
2. Read the relevant reference document in `references/` for detailed APIs and patterns.
|
||||
3. Suggest the required package installation if not already present.
|
||||
4. Implement the scaffold for the animation structure, adhering to the requested format (React components, hook requirements, or vanilla JS).
|
||||
5. Apply the correct tools (scrolling vs in-view elements) ensuring accessibility options are present and hooks don't cause infinite re-renders.
|
||||
|
||||
## The 5 Most Common Scroll Patterns
|
||||
|
||||
Quick reference — full recipes with Copilot prompts are in the reference files.
|
||||
|
||||
### 1. Fade-in on enter (GSAP)
|
||||
```js
|
||||
gsap.from('.card', {
|
||||
opacity: 0, y: 50, stagger: 0.15, duration: 0.8,
|
||||
scrollTrigger: { trigger: '.card', start: 'top 85%' }
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Fade-in on enter (Framer Motion)
|
||||
```jsx
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. Scrub / scroll-linked (GSAP)
|
||||
```js
|
||||
gsap.to('.hero-img', {
|
||||
scale: 1.3, opacity: 0, ease: 'none',
|
||||
scrollTrigger: { trigger: '.hero', start: 'top top', end: 'bottom top', scrub: true }
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Scroll-linked (Framer Motion)
|
||||
```jsx
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'end start'] });
|
||||
const y = useTransform(scrollYProgress, [0, 1], [0, -100]);
|
||||
return <motion.div style={{ y }} />;
|
||||
```
|
||||
|
||||
### 5. Pinned timeline (GSAP)
|
||||
```js
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: '.section', pin: true, scrub: 1, start: 'top top', end: '+=200%' }
|
||||
});
|
||||
tl.from('.title', { opacity: 0, y: 60 }).from('.img', { scale: 0.85 });
|
||||
```
|
||||
|
||||
## Critical Rules (Apply Always)
|
||||
|
||||
- **GSAP**: always call `gsap.registerPlugin(ScrollTrigger)` before using it
|
||||
- **GSAP scrub**: always use `ease: 'none'` — easing feels wrong when scrub is active
|
||||
- **GSAP React**: use `useGSAP` from `@gsap/react`, never plain `useEffect` — it auto-cleans ScrollTriggers
|
||||
- **GSAP debug**: add `markers: true` during development; remove before production
|
||||
- **Framer**: `useTransform` output must go into `style` prop of a `motion.*` element, not a plain div
|
||||
- **Framer Next.js**: always add `'use client'` at top of any file using motion hooks
|
||||
- **Both**: animate only `transform` and `opacity` — avoid `width`, `height`, `box-shadow`
|
||||
- **Accessibility**: always check `prefers-reduced-motion` — see each reference file for patterns
|
||||
- **Premium polish**: follow the **premium-frontend-ui** skill principles for motion timing, easing curves, and restraint — animation should enhance, never overwhelm
|
||||
|
||||
## Copilot Prompting Tips
|
||||
|
||||
- Give Copilot the full selector, base image, and scroll range upfront — vague prompts produce vague code
|
||||
- For GSAP, always specify: selector, start/end strings, whether you want scrub or toggleActions
|
||||
- For Framer, always specify: which hook (useScroll vs whileInView), offset values, what to transform
|
||||
- Paste the exact error message when asking `/fix` — Copilot fixes are dramatically better with real errors
|
||||
- Use `@workspace` scope in Copilot Chat so it reads your existing component structure
|
||||
|
||||
## Reference Files
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `references/gsap.md` | Full ScrollTrigger API reference, 10 recipes, React (useGSAP), Lenis, matchMedia, accessibility |
|
||||
| `references/framer.md` | Full useScroll / useTransform API, 8 recipes, variants, Motion v12 notes, Next.js tips |
|
||||
|
||||
## Related Skills
|
||||
|
||||
| Skill | Relationship |
|
||||
|---|---|
|
||||
| **premium-frontend-ui** | Creative philosophy, design principles, and aesthetic guidelines — defines *when* and *why* to animate |
|
||||
675
skills/gsap-framer-scroll-animation/references/framer.md
Normal file
675
skills/gsap-framer-scroll-animation/references/framer.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# Framer Motion (Motion v12) — Full Reference
|
||||
|
||||
> Framer Motion was renamed to **Motion** in mid-2025. The npm package is now `motion`,
|
||||
> the import path is `motion/react`. All APIs are identical. `framer-motion` still works.
|
||||
|
||||
## Table of Contents
|
||||
1. [Package & Import Paths](#package--import-paths)
|
||||
2. [Two Types of Scroll Animation](#two-types-of-scroll-animation)
|
||||
3. [useScroll — Options Reference](#usescroll--options-reference)
|
||||
4. [useTransform — Full Reference](#usetransform--full-reference)
|
||||
5. [useSpring for Smoothing](#usespring-for-smoothing)
|
||||
6. [Recipes with Copilot Prompts](#recipes-with-copilot-prompts)
|
||||
- Scroll progress bar
|
||||
- Reusable ScrollReveal wrapper
|
||||
- Parallax layers
|
||||
- Horizontal scroll section
|
||||
- Image reveal with clipPath
|
||||
- Scroll-linked navbar (hide/show)
|
||||
- Staggered card grid
|
||||
- 3D tilt on scroll
|
||||
7. [Variants Pattern for Stagger](#variants-pattern-for-stagger)
|
||||
8. [Motion Value Events](#motion-value-events)
|
||||
9. [Next.js & App Router Notes](#nextjs--app-router-notes)
|
||||
10. [Accessibility](#accessibility)
|
||||
11. [Common Copilot Pitfalls](#common-copilot-pitfalls)
|
||||
|
||||
---
|
||||
|
||||
## Package & Import Paths
|
||||
|
||||
```bash
|
||||
npm install motion # recommended (renamed 2025)
|
||||
npm install framer-motion # still works — same API
|
||||
```
|
||||
|
||||
```js
|
||||
// Recommended (Motion v12+)
|
||||
import { motion, useScroll, useTransform, useSpring, useMotionValueEvent } from 'motion/react';
|
||||
|
||||
// Legacy — still valid
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
```
|
||||
|
||||
**Motion v12 new features (2025):**
|
||||
- Hardware-accelerated scroll via browser ScrollTimeline API
|
||||
- `useScroll` and `scroll()` now GPU-accelerated by default
|
||||
- New color types: `oklch`, `oklab`, `color-mix` animatable directly
|
||||
- Full React 19 + concurrent rendering support
|
||||
|
||||
---
|
||||
|
||||
## Two Types of Scroll Animation
|
||||
|
||||
### Scroll-triggered (fires once when element enters viewport)
|
||||
|
||||
```jsx
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.6, ease: [0.21, 0.47, 0.32, 0.98] }}
|
||||
>
|
||||
Content
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
`viewport.margin` — negative value triggers animation before element fully enters view.
|
||||
`viewport.once` — `true` means animate once, never reverse.
|
||||
|
||||
### Scroll-linked (continuous, tied to scroll position)
|
||||
|
||||
```jsx
|
||||
const { scrollYProgress } = useScroll();
|
||||
const opacity = useTransform(scrollYProgress, [0, 1], [0, 1]);
|
||||
return <motion.div style={{ opacity }}>Content</motion.div>;
|
||||
```
|
||||
|
||||
The value updates on every scroll frame — must use `style` prop, not `animate`.
|
||||
|
||||
---
|
||||
|
||||
## useScroll — Options Reference
|
||||
|
||||
```js
|
||||
const {
|
||||
scrollX, // Absolute horizontal scroll (pixels)
|
||||
scrollY, // Absolute vertical scroll (pixels)
|
||||
scrollXProgress, // Horizontal progress 0→1 between offsets
|
||||
scrollYProgress, // Vertical progress 0→1 between offsets
|
||||
} = useScroll({
|
||||
// Track a scrollable element instead of the viewport
|
||||
container: containerRef,
|
||||
|
||||
// Track an element's position within the container
|
||||
target: targetRef,
|
||||
|
||||
// Define when tracking starts and ends
|
||||
// Format: ["target position container position", "target position container position"]
|
||||
offset: ['start end', 'end start'],
|
||||
// Common offset pairs:
|
||||
// ['start end', 'end start'] = track while element is anywhere in view
|
||||
// ['start end', 'end end'] = track from element entering to bottom of page
|
||||
// ['start start', 'end start'] = track while element exits top
|
||||
// ['center center', 'end start']= track from center-center to exit
|
||||
|
||||
// Update when content size changes (small perf cost, false by default)
|
||||
trackContentSize: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Offset string values:**
|
||||
- `start` = `0` = top/left edge
|
||||
- `center` = `0.5` = middle
|
||||
- `end` = `1` = bottom/right edge
|
||||
- Numbers 0–1 also work: `[0, 1]` = `['start', 'end']`
|
||||
|
||||
---
|
||||
|
||||
## useTransform — Full Reference
|
||||
|
||||
```js
|
||||
// Map a motion value from one range to another
|
||||
const y = useTransform(scrollYProgress, [0, 1], [0, -200]);
|
||||
|
||||
// Multi-stop interpolation
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.2, 0.8, 1],
|
||||
[0, 1, 1, 0]
|
||||
);
|
||||
|
||||
// Non-numeric values (colors, strings)
|
||||
const color = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.5, 1],
|
||||
['#6366f1', '#ec4899', '#f97316']
|
||||
);
|
||||
|
||||
// CSS string values
|
||||
const clipPath = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 1],
|
||||
['inset(0% 100% 0% 0%)', 'inset(0% 0% 0% 0%)']
|
||||
);
|
||||
|
||||
// Disable clamping (allow values outside output range)
|
||||
const y = useTransform(scrollYProgress, [0, 1], [0, -200], { clamp: false });
|
||||
|
||||
// Transform from multiple inputs
|
||||
const combined = useTransform(
|
||||
[scrollX, scrollY],
|
||||
([x, y]) => Math.sqrt(x * x + y * y)
|
||||
);
|
||||
```
|
||||
|
||||
**Rule:** `useTransform` output is a `MotionValue`. It must go into the `style` prop of a `motion.*` element. Plain `<div style={{ y }}>` will NOT work — must be `<motion.div style={{ y }}>`.
|
||||
|
||||
---
|
||||
|
||||
## useSpring for Smoothing
|
||||
|
||||
Wrap any MotionValue in `useSpring` to add spring physics — great for progress bars that feel alive.
|
||||
|
||||
```js
|
||||
const { scrollYProgress } = useScroll();
|
||||
|
||||
const smooth = useSpring(scrollYProgress, {
|
||||
stiffness: 100, // Higher = faster/snappier response
|
||||
damping: 30, // Higher = less bounce
|
||||
restDelta: 0.001 // Precision threshold for stopping
|
||||
});
|
||||
|
||||
return <motion.div style={{ scaleX: smooth }} />;
|
||||
```
|
||||
|
||||
For a subtle lag (not physics), use `useTransform` with `clamp: false` and an eased range instead.
|
||||
|
||||
---
|
||||
|
||||
## Recipes with Copilot Prompts
|
||||
|
||||
### 1. Scroll Progress Bar
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion: fixed scroll progress bar at top of page.
|
||||
useScroll for page scroll progress, useSpring to smooth scaleX.
|
||||
stiffness 100, damping 30. Grows left to right.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useScroll, useSpring, motion } from 'motion/react';
|
||||
|
||||
export function ScrollProgressBar() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100, damping: 30, restDelta: 0.001,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={{ scaleX }}
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-indigo-500 origin-left z-50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Reusable ScrollReveal Wrapper
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion: reusable ScrollReveal component that wraps children with
|
||||
fade-in-up entrance animation using whileInView. Props: delay (default 0),
|
||||
duration (default 0.6), once (default true). viewport margin -80px.
|
||||
TypeScript. 'use client'.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
once?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrollReveal({
|
||||
children, delay = 0, duration = 0.6, once = true, className
|
||||
}: ScrollRevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once, margin: '-80px' }}
|
||||
transition={{ duration, delay, ease: [0.21, 0.47, 0.32, 0.98] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage:
|
||||
// <ScrollReveal delay={0.2}><h2>Section Title</h2></ScrollReveal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Parallax Layers
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion parallax section: background moves y from 0% to 30% (slow),
|
||||
foreground text moves y from 50 to -50px (fast).
|
||||
Both use target ref with offset ['start end', 'end start'].
|
||||
Fade out at top and bottom using opacity useTransform [0, 0.3, 0.7, 1] → [0,1,1,0].
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'motion/react';
|
||||
|
||||
export function ParallaxSection() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '30%']);
|
||||
const textY = useTransform(scrollYProgress, [0, 1], [50, -50]);
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
|
||||
|
||||
return (
|
||||
<section ref={ref} className="relative h-screen overflow-hidden flex items-center justify-center">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: 'url(/hero-bg.jpg)', y: backgroundY, scale: 1.2 }}
|
||||
/>
|
||||
<motion.div style={{ y: textY, opacity }} className="relative z-10 text-center text-white">
|
||||
<h2 className="text-6xl font-bold">Parallax Title</h2>
|
||||
<p className="text-xl mt-4">Scrolls at a different speed</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Horizontal Scroll Section
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion horizontal scroll: 4 cards scroll horizontally as user scrolls vertically.
|
||||
Outer container ref height 300vh controls speed (sticky pattern).
|
||||
useScroll tracks outer container, useTransform maps scrollYProgress to x '0%' → '-75%'.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'motion/react';
|
||||
|
||||
const cards = [
|
||||
{ id: 1, title: 'Card One', color: 'bg-indigo-500' },
|
||||
{ id: 2, title: 'Card Two', color: 'bg-pink-500' },
|
||||
{ id: 3, title: 'Card Three', color: 'bg-amber-500' },
|
||||
{ id: 4, title: 'Card Four', color: 'bg-teal-500' },
|
||||
];
|
||||
|
||||
export function HorizontalScroll() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start start', 'end end'],
|
||||
});
|
||||
|
||||
const x = useTransform(scrollYProgress, [0, 1], ['0%', '-75%']);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-[300vh]">
|
||||
<div className="sticky top-0 h-screen overflow-hidden">
|
||||
<motion.div
|
||||
style={{ x, width: `${cards.length * 100}vw` }}
|
||||
className="flex gap-6 h-full items-center px-8"
|
||||
>
|
||||
{cards.map(card => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`${card.color} w-screen h-[70vh] rounded-2xl flex items-center justify-center flex-shrink-0`}
|
||||
>
|
||||
<h3 className="text-white text-4xl font-bold">{card.title}</h3>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Image Reveal with clipPath
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion: image reveals left to right as it scrolls into view.
|
||||
useScroll target ref, offset ['start end', 'center center'].
|
||||
useTransform clipPath from 'inset(0% 100% 0% 0%)' to 'inset(0% 0% 0% 0%)'.
|
||||
Also scale from 1.15 to 1.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'motion/react';
|
||||
|
||||
export function ImageReveal({ src, alt }: { src: string; alt: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const clipPath = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 1],
|
||||
['inset(0% 100% 0% 0%)', 'inset(0% 0% 0% 0%)']
|
||||
);
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1.15, 1]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="overflow-hidden rounded-xl">
|
||||
<motion.img
|
||||
src={src} alt={alt}
|
||||
style={{ clipPath, scale }}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Scroll-linked Navbar (Hide on Scroll Down)
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion navbar: transparent when at top, white with shadow after 80px.
|
||||
Hide by sliding up when scrolling down, reveal when scrolling up.
|
||||
Use useScroll, useMotionValueEvent to detect direction.
|
||||
Animate y, backgroundColor, boxShadow with motion.nav.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useRef, useState } from 'react';
|
||||
import { motion, useScroll, useMotionValueEvent } from 'motion/react';
|
||||
|
||||
export function Navbar() {
|
||||
const { scrollY } = useScroll();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const prevRef = useRef(0);
|
||||
|
||||
useMotionValueEvent(scrollY, 'change', latest => {
|
||||
const nextScrolled = latest > 80;
|
||||
const nextHidden = latest > prevRef.current && latest > 200;
|
||||
setScrolled(current => (current === nextScrolled ? current : nextScrolled));
|
||||
setHidden(current => (current === nextHidden ? current : nextHidden));
|
||||
prevRef.current = latest;
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
animate={{
|
||||
y: hidden ? -80 : 0,
|
||||
backgroundColor: scrolled ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0)',
|
||||
boxShadow: scrolled ? '0 1px 24px rgba(0,0,0,0.08)' : 'none',
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="fixed top-0 left-0 right-0 z-50 backdrop-blur-sm"
|
||||
>
|
||||
{/* nav links */}
|
||||
</motion.nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Staggered Card Grid
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion: card grid with stagger entrance. Use variants:
|
||||
container has staggerChildren 0.1, delayChildren 0.2.
|
||||
Each card: hidden (opacity 0, y 40, scale 0.96) → visible (opacity 1, y 0, scale 1).
|
||||
Trigger with whileInView on the container. Once.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 40, scale: 0.96 },
|
||||
visible: {
|
||||
opacity: 1, y: 0, scale: 1,
|
||||
transition: { duration: 0.5, ease: [0.21, 0.47, 0.32, 0.98] }
|
||||
}
|
||||
};
|
||||
|
||||
export function CardGrid({ cards }: { cards: { id: number; title: string }[] }) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
className="grid grid-cols-3 gap-6"
|
||||
>
|
||||
{cards.map(card => (
|
||||
<motion.div key={card.id} variants={cardVariants}
|
||||
className="bg-white rounded-xl p-6 shadow-sm border"
|
||||
>
|
||||
<h3>{card.title}</h3>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. 3D Tilt on Scroll
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Framer Motion: 3D perspective card that rotates on X axis as it scrolls through view.
|
||||
rotateX 15→0→-15, scale 0.9→1→0.9, opacity 0→1→0.
|
||||
Target ref with offset ['start end', 'end start']. Wrap in perspective container.
|
||||
```
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'motion/react';
|
||||
|
||||
export function TiltCard({ children }: { children: React.ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
const rotateX = useTransform(scrollYProgress, [0, 0.5, 1], [15, 0, -15]);
|
||||
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.9, 1, 0.9]);
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ perspective: '1000px' }}>
|
||||
<motion.div
|
||||
style={{ rotateX, scale, opacity }}
|
||||
className="bg-white rounded-2xl p-8 shadow-lg"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variants Pattern for Stagger
|
||||
|
||||
Variants propagate automatically from parent to children — you don't need to pass them down manually.
|
||||
|
||||
```tsx
|
||||
const parent = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.1, // Delay between each child
|
||||
delayChildren: 0.2, // Initial delay before first child
|
||||
when: 'beforeChildren', // Parent animates before children
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const child = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }
|
||||
};
|
||||
|
||||
// Children with `variants={child}` automatically get the stagger
|
||||
// when the parent transitions between 'hidden' and 'visible'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Motion Value Events
|
||||
|
||||
```tsx
|
||||
import { useScroll, useMotionValueEvent } from 'motion/react';
|
||||
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
// Fires on every change — use for imperative side effects
|
||||
useMotionValueEvent(scrollY, 'change', latest => {
|
||||
console.log('scroll position:', latest);
|
||||
});
|
||||
|
||||
// Detect scroll direction
|
||||
const [direction, setDirection] = useState<'up' | 'down'>('down');
|
||||
|
||||
useMotionValueEvent(scrollY, 'change', current => {
|
||||
const diff = current - scrollY.getPrevious()!;
|
||||
setDirection(diff > 0 ? 'down' : 'up');
|
||||
});
|
||||
```
|
||||
|
||||
**When to use `useMotionValueEvent` vs `useTransform`:**
|
||||
- Use `useTransform` when you want a CSS value that animates smoothly (y, opacity, color)
|
||||
- Use `useMotionValueEvent` when you want to fire React state changes or side effects
|
||||
|
||||
---
|
||||
|
||||
## Next.js & App Router Notes
|
||||
|
||||
```tsx
|
||||
// Every file using motion hooks must be a Client Component
|
||||
'use client';
|
||||
|
||||
// For page-level scroll tracking in App Router, use useScroll in a layout
|
||||
// that's already a client component — don't try to use it in Server Components
|
||||
|
||||
// If you need SSR-safe scroll animations, gate with:
|
||||
import { useEffect, useState } from 'react';
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null; // or a skeleton
|
||||
```
|
||||
|
||||
**Recommended pattern for Next.js App Router:**
|
||||
1. Keep all `motion.*` components in separate `'use client'` files
|
||||
2. Import them into Server Components — they'll be client-rendered automatically
|
||||
3. Use `AnimatePresence` at the layout level for page transitions
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
```tsx
|
||||
import { useReducedMotion } from 'motion/react';
|
||||
|
||||
export function AnimatedCard() {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.6 }}
|
||||
>
|
||||
Content
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Or disable all scroll-linked transforms when reduced motion is preferred:
|
||||
```tsx
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
const y = useTransform(
|
||||
scrollYProgress, [0, 1],
|
||||
prefersReducedMotion ? [0, 0] : [100, -100] // no movement if reduced motion
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Copilot Pitfalls
|
||||
|
||||
**Missing 'use client':** Copilot forgets to add this for Next.js App Router files.
|
||||
Every file using `useScroll`, `useTransform`, `motion.*`, or any hook needs `'use client'` at the top.
|
||||
|
||||
**Using style prop on a plain div:** Copilot sometimes writes `<div style={{ y }}>` where `y` is a MotionValue.
|
||||
This silently does nothing. Must be `<motion.div style={{ y }}>`.
|
||||
|
||||
**Old import path:** Copilot still generates `from 'framer-motion'` (valid, but legacy).
|
||||
Current canonical: `from 'motion/react'`.
|
||||
|
||||
**Forgetting offset on useScroll:** Without `offset`, `scrollYProgress` tracks the full page
|
||||
from 0 to 1 — not the element's position. Always pass `target` + `offset` for element-level tracking.
|
||||
|
||||
**Missing ref on target:** Copilot sometimes writes `target: ref` but forgets to attach `ref` to the DOM element.
|
||||
```tsx
|
||||
const ref = useRef(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref }); // ← ref passed
|
||||
return <div ref={ref}>...</div>; // ← ref attached
|
||||
```
|
||||
|
||||
**Using animate prop for scroll-linked values:** Scroll-linked values must use `style`, not `animate`.
|
||||
`animate` runs on mount/unmount, not on scroll.
|
||||
```tsx
|
||||
// ❌ Wrong
|
||||
<motion.div animate={{ opacity }} />
|
||||
|
||||
// ✅ Correct
|
||||
<motion.div style={{ opacity }} />
|
||||
```
|
||||
|
||||
**Not smoothing scroll progress:** Raw `scrollYProgress` can feel mechanical on fine motion.
|
||||
Wrap in `useSpring` for progress bars and UI elements that need a polished feel.
|
||||
684
skills/gsap-framer-scroll-animation/references/gsap.md
Normal file
684
skills/gsap-framer-scroll-animation/references/gsap.md
Normal file
@@ -0,0 +1,684 @@
|
||||
# GSAP ScrollTrigger — Full Reference
|
||||
|
||||
## Table of Contents
|
||||
1. [Installation & Registration](#installation--registration)
|
||||
2. [ScrollTrigger Config Reference](#scrolltrigger-config-reference)
|
||||
3. [Start / End Syntax Decoded](#start--end-syntax-decoded)
|
||||
4. [toggleActions Values](#toggleactions-values)
|
||||
5. [Recipes with Copilot Prompts](#recipes-with-copilot-prompts)
|
||||
- Fade-in batch reveal
|
||||
- Scrub animation
|
||||
- Pinned timeline
|
||||
- Parallax layers
|
||||
- Horizontal scroll
|
||||
- Character stagger text
|
||||
- Scroll snap
|
||||
- Progress bar
|
||||
- ScrollSmoother
|
||||
- Scroll counter
|
||||
6. [React Integration (useGSAP)](#react-integration-usegsap)
|
||||
7. [Lenis Smooth Scroll](#lenis-smooth-scroll)
|
||||
8. [Responsive with matchMedia](#responsive-with-matchmedia)
|
||||
9. [Accessibility](#accessibility)
|
||||
10. [Performance & Cleanup](#performance--cleanup)
|
||||
11. [Common Copilot Pitfalls](#common-copilot-pitfalls)
|
||||
|
||||
---
|
||||
|
||||
## Installation & Registration
|
||||
|
||||
```bash
|
||||
npm install gsap
|
||||
# React
|
||||
npm install gsap @gsap/react
|
||||
```
|
||||
|
||||
```js
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { ScrollSmoother } from 'gsap/ScrollSmoother'; // optional
|
||||
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
|
||||
```
|
||||
|
||||
CDN (vanilla):
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14/dist/ScrollTrigger.min.js"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ScrollTrigger Config Reference
|
||||
|
||||
```js
|
||||
gsap.to('.element', {
|
||||
x: 500,
|
||||
ease: 'none', // Use 'none' for scrub animations
|
||||
scrollTrigger: {
|
||||
trigger: '.section', // Element whose position triggers the animation
|
||||
start: 'top 80%', // "[trigger edge] [viewport edge]"
|
||||
end: 'bottom 20%', // Where animation ends
|
||||
scrub: 1, // Link progress to scroll; use true for instant scrub, or a number for smooth lag
|
||||
pin: true, // Pin trigger element during scroll; use a selector/element to pin something else
|
||||
pinSpacing: true, // Add space below pinned element (default: true)
|
||||
markers: true, // Debug markers — REMOVE in production
|
||||
toggleActions: 'play none none reverse', // onEnter onLeave onEnterBack onLeaveBack
|
||||
toggleClass: 'active', // CSS class added/removed when active
|
||||
snap: { snapTo: 'labels', duration: 0.3, ease: 'power1.inOut' }, // Or use a number like 1 to snap to increments
|
||||
fastScrollEnd: true, // Force completion if user scrolls past fast
|
||||
horizontal: false, // true for horizontal scroll containers
|
||||
anticipatePin: 1, // Reduces pin jump (seconds to anticipate)
|
||||
invalidateOnRefresh: true, // Recalculate positions on resize
|
||||
id: 'my-trigger', // For ScrollTrigger.getById()
|
||||
onEnter: () => {},
|
||||
onLeave: () => {},
|
||||
onEnterBack: () => {},
|
||||
onLeaveBack: () => {},
|
||||
onUpdate: self => console.log(self.progress), // 0 to 1
|
||||
onToggle: self => console.log(self.isActive),
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Start / End Syntax Decoded
|
||||
|
||||
Format: `"[trigger position] [viewport position]"`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `"top bottom"` | Top of trigger hits bottom of viewport — enters view |
|
||||
| `"top 80%"` | Top of trigger reaches 80% down from top of viewport |
|
||||
| `"top center"` | Top of trigger reaches viewport center |
|
||||
| `"top top"` | Top of trigger at top of viewport |
|
||||
| `"center center"` | Centers align |
|
||||
| `"bottom top"` | Bottom of trigger at top of viewport — exits view |
|
||||
| `"+=200"` | 200px after trigger position |
|
||||
| `"-=100"` | 100px before trigger position |
|
||||
| `"+=200%"` | 200% of viewport height after trigger |
|
||||
|
||||
---
|
||||
|
||||
## toggleActions Values
|
||||
|
||||
```
|
||||
toggleActions: "play pause resume reset"
|
||||
^ ^ ^ ^
|
||||
onEnter onLeave onEnterBack onLeaveBack
|
||||
```
|
||||
|
||||
| Value | Effect |
|
||||
|---|---|
|
||||
| `play` | Play from current position |
|
||||
| `pause` | Pause at current position |
|
||||
| `resume` | Resume from where paused |
|
||||
| `reverse` | Play backwards |
|
||||
| `reset` | Jump to start |
|
||||
| `restart` | Play from beginning |
|
||||
| `none` | Do nothing |
|
||||
|
||||
Most common for entrance animations: `"play none none none"` (animate once, don't reverse).
|
||||
|
||||
---
|
||||
|
||||
## Recipes with Copilot Prompts
|
||||
|
||||
### 1. Fade-in Batch Reveal
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Using GSAP ScrollTrigger.batch, animate all .card elements:
|
||||
fade in from opacity 0, y 50 when they enter the viewport at 85%.
|
||||
Stagger 0.15s between cards. Animate once (no reverse).
|
||||
```
|
||||
|
||||
```js
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
ScrollTrigger.batch('.card', {
|
||||
onEnter: elements => {
|
||||
gsap.from(elements, {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
stagger: 0.15,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out',
|
||||
});
|
||||
},
|
||||
start: 'top 85%',
|
||||
});
|
||||
```
|
||||
|
||||
Why `batch` over individual ScrollTriggers: batch groups elements entering together into one animation call, which is more performant than creating one ScrollTrigger per element.
|
||||
|
||||
---
|
||||
|
||||
### 2. Scrub Animation (scroll-linked)
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP scrub: animate .hero-image scale from 1 to 1.3 and opacity to 0
|
||||
as the user scrolls past .hero-section.
|
||||
Perfectly synced to scroll position, no pin.
|
||||
```
|
||||
|
||||
```js
|
||||
gsap.to('.hero-image', {
|
||||
scale: 1.3,
|
||||
opacity: 0,
|
||||
ease: 'none', // Critical: linear easing for scrub
|
||||
scrollTrigger: {
|
||||
trigger: '.hero-section',
|
||||
start: 'top top',
|
||||
end: 'bottom top',
|
||||
scrub: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Pinned Timeline
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP pinned timeline: pin .story-section while a sequence plays —
|
||||
fade in .title (y: 60), scale .image to 1, slide .text from x: 80.
|
||||
Total scroll distance 300vh. Scrub 1 for smoothness.
|
||||
```
|
||||
|
||||
```js
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: '.story-section',
|
||||
start: 'top top',
|
||||
end: '+=300%',
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
anticipatePin: 1,
|
||||
}
|
||||
});
|
||||
|
||||
tl
|
||||
.from('.title', { opacity: 0, y: 60, duration: 1 })
|
||||
.from('.image', { scale: 0.85, opacity: 0, duration: 1 }, '-=0.3')
|
||||
.from('.text', { x: 80, opacity: 0, duration: 1 }, '-=0.3');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Parallax Layers
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP parallax: background image moves yPercent -20 (slow),
|
||||
foreground text moves yPercent -60 (fast). Both scrubbed to scroll, no pin.
|
||||
Trigger is .parallax-section, start top bottom, end bottom top.
|
||||
```
|
||||
|
||||
```js
|
||||
// Slow background
|
||||
gsap.to('.parallax-bg', {
|
||||
yPercent: -20,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.parallax-section',
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
scrub: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Fast foreground
|
||||
gsap.to('.parallax-fg', {
|
||||
yPercent: -60,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.parallax-section',
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
scrub: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Horizontal Scroll Section
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP horizontal scroll: 4 .panel elements inside .panels-container.
|
||||
Pin .horizontal-section, scrub 1, snap per panel.
|
||||
End should use offsetWidth so it recalculates on resize.
|
||||
```
|
||||
|
||||
```js
|
||||
const sections = gsap.utils.toArray('.panel');
|
||||
|
||||
gsap.to(sections, {
|
||||
xPercent: -100 * (sections.length - 1),
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.horizontal-section',
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
snap: 1 / (sections.length - 1),
|
||||
end: () => `+=${document.querySelector('.panels-container').offsetWidth}`,
|
||||
invalidateOnRefresh: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Required HTML:
|
||||
```html
|
||||
<div class="horizontal-section">
|
||||
<div class="panels-container">
|
||||
<div class="panel">1</div>
|
||||
<div class="panel">2</div>
|
||||
<div class="panel">3</div>
|
||||
<div class="panel">4</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Required CSS:
|
||||
```css
|
||||
.horizontal-section { overflow: hidden; }
|
||||
.panels-container { display: flex; flex-wrap: nowrap; width: 400vw; }
|
||||
.panel { width: 100vw; height: 100vh; flex-shrink: 0; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Character Stagger Text Reveal
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Split .hero-title into characters using SplitType.
|
||||
Animate each char: opacity 0→1, y 80→0, rotateX -90→0.
|
||||
Stagger 0.03s, ease back.out(1.7). Trigger when heading enters at 85%.
|
||||
```
|
||||
|
||||
```bash
|
||||
npm install split-type
|
||||
```
|
||||
|
||||
```js
|
||||
import SplitType from 'split-type';
|
||||
|
||||
const text = new SplitType('.hero-title', { types: 'chars' });
|
||||
|
||||
gsap.from(text.chars, {
|
||||
opacity: 0,
|
||||
y: 80,
|
||||
rotateX: -90,
|
||||
stagger: 0.03,
|
||||
duration: 0.6,
|
||||
ease: 'back.out(1.7)',
|
||||
scrollTrigger: {
|
||||
trigger: '.hero-title',
|
||||
start: 'top 85%',
|
||||
toggleActions: 'play none none none',
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Scroll Snap Sections
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP: each full-height section scales from 0.9 to 1 when it enters view.
|
||||
Also add global scroll snapping between sections using ScrollTrigger.create snap.
|
||||
```
|
||||
|
||||
```js
|
||||
const sections = gsap.utils.toArray('section');
|
||||
|
||||
sections.forEach(section => {
|
||||
gsap.from(section, {
|
||||
scale: 0.9,
|
||||
opacity: 0.6,
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 90%',
|
||||
toggleActions: 'play none none reverse',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ScrollTrigger.create({
|
||||
snap: {
|
||||
snapTo: (progress) => {
|
||||
const step = 1 / (sections.length - 1);
|
||||
return Math.round(progress / step) * step;
|
||||
},
|
||||
duration: { min: 0.2, max: 0.5 },
|
||||
ease: 'power1.inOut',
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Scroll Progress Bar
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP: fixed progress bar at top of page. scaleX 0→1 linked to
|
||||
full page scroll, scrub 0.3 for slight smoothing. transformOrigin left center.
|
||||
```
|
||||
|
||||
```js
|
||||
gsap.to('.progress-bar', {
|
||||
scaleX: 1,
|
||||
ease: 'none',
|
||||
transformOrigin: 'left center',
|
||||
scrollTrigger: {
|
||||
trigger: document.body,
|
||||
start: 'top top',
|
||||
end: 'bottom bottom',
|
||||
scrub: 0.3,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
```css
|
||||
.progress-bar {
|
||||
position: fixed; top: 0; left: 0;
|
||||
width: 100%; height: 4px;
|
||||
background: #6366f1;
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
z-index: 999;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. ScrollSmoother Setup
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Set up GSAP ScrollSmoother with smooth: 1.5, effects: true.
|
||||
Show the required wrapper HTML structure.
|
||||
Add data-speed and data-lag to parallax elements.
|
||||
```
|
||||
|
||||
```bash
|
||||
# ScrollSmoother is part of gsap — no extra install needed
|
||||
```
|
||||
|
||||
```js
|
||||
import { ScrollSmoother } from 'gsap/ScrollSmoother';
|
||||
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
|
||||
|
||||
ScrollSmoother.create({
|
||||
wrapper: '#smooth-wrapper',
|
||||
content: '#smooth-content',
|
||||
smooth: 1.5,
|
||||
effects: true,
|
||||
smoothTouch: 0.1,
|
||||
});
|
||||
```
|
||||
|
||||
```html
|
||||
<div id="smooth-wrapper">
|
||||
<div id="smooth-content">
|
||||
<img data-speed="0.5" src="bg.jpg" /> <!-- 50% scroll speed -->
|
||||
<div data-lag="0.3" class="float">...</div> <!-- 0.3s lag -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Animated Number Counter
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
GSAP: animate .counter elements from 0 to their data-target value
|
||||
when they enter the viewport. Duration 2s, ease power2.out.
|
||||
Format with toLocaleString. Animate once.
|
||||
```
|
||||
|
||||
```js
|
||||
document.querySelectorAll('.counter').forEach(el => {
|
||||
const obj = { val: 0 };
|
||||
gsap.to(obj, {
|
||||
val: parseInt(el.dataset.target, 10),
|
||||
duration: 2,
|
||||
ease: 'power2.out',
|
||||
onUpdate: () => { el.textContent = Math.round(obj.val).toLocaleString(); },
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: 'top 85%',
|
||||
toggleActions: 'play none none none',
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```html
|
||||
<span class="counter" data-target="12500">0</span>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Integration (useGSAP)
|
||||
|
||||
```bash
|
||||
npm install gsap @gsap/react
|
||||
```
|
||||
|
||||
```jsx
|
||||
import { useRef } from 'react';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(useGSAP, ScrollTrigger);
|
||||
```
|
||||
|
||||
**Why useGSAP instead of useEffect:**
|
||||
`useGSAP` automatically kills all ScrollTriggers created inside it when the component unmounts — preventing memory leaks. It also handles React strict mode's double-invoke correctly. Think of it as a drop-in replacement for `useLayoutEffect` that GSAP understands.
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
React: use useGSAP from @gsap/react to animate .card elements inside containerRef.
|
||||
Fade in from y 60, opacity 0, stagger 0.12, scrollTrigger start top 80%.
|
||||
Scope to containerRef so selectors don't match outside this component.
|
||||
```
|
||||
|
||||
```jsx
|
||||
export function AnimatedSection() {
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useGSAP(() => {
|
||||
gsap.from('.card', {
|
||||
opacity: 0,
|
||||
y: 60,
|
||||
stagger: 0.12,
|
||||
duration: 0.7,
|
||||
ease: 'power2.out',
|
||||
scrollTrigger: {
|
||||
trigger: containerRef.current,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none none',
|
||||
}
|
||||
});
|
||||
}, { scope: containerRef });
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<div className="card">One</div>
|
||||
<div className="card">Two</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Pinned timeline in React:**
|
||||
```jsx
|
||||
export function PinnedStory() {
|
||||
const sectionRef = useRef(null);
|
||||
|
||||
useGSAP(() => {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
pin: true, scrub: 1,
|
||||
start: 'top top', end: '+=200%',
|
||||
}
|
||||
});
|
||||
tl.from('.story-title', { opacity: 0, y: 40 })
|
||||
.from('.story-image', { scale: 0.85, opacity: 0 }, '-=0.2')
|
||||
.from('.story-text', { opacity: 0, x: 40 }, '-=0.2');
|
||||
}, { scope: sectionRef });
|
||||
|
||||
return (
|
||||
<section ref={sectionRef}>
|
||||
<h2 className="story-title">Chapter One</h2>
|
||||
<img className="story-image" src="/photo.jpg" alt="" />
|
||||
<p className="story-text">The story begins.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Next.js note:** Run `gsap.registerPlugin(ScrollTrigger)` inside a `useGSAP` or `useLayoutEffect` — or guard it:
|
||||
```js
|
||||
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lenis Smooth Scroll
|
||||
|
||||
```bash
|
||||
npm install lenis
|
||||
```
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Integrate Lenis smooth scroll with GSAP ScrollTrigger.
|
||||
Add lenis.raf to gsap.ticker. Set lagSmoothing to 0.
|
||||
Destroy lenis on unmount if in React.
|
||||
```
|
||||
|
||||
```js
|
||||
import Lenis from 'lenis';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const lenis = new Lenis({ duration: 1.2, smoothWheel: true });
|
||||
|
||||
const raf = (time) => lenis.raf(time * 1000);
|
||||
gsap.ticker.add(raf);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
|
||||
// React cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
lenis.destroy();
|
||||
gsap.ticker.remove(raf);
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive with matchMedia
|
||||
|
||||
**Copilot Chat Prompt:**
|
||||
```
|
||||
Use gsap.matchMedia to animate x: 200 on desktop (min-width: 768px)
|
||||
and y: 100 on mobile. Both should skip animation if prefers-reduced-motion is set.
|
||||
```
|
||||
|
||||
```js
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
mm.add({
|
||||
isDesktop: '(min-width: 768px)',
|
||||
isMobile: '(max-width: 767px)',
|
||||
noMotion: '(prefers-reduced-motion: reduce)',
|
||||
}, context => {
|
||||
const { isDesktop, isMobile, noMotion } = context.conditions;
|
||||
if (noMotion) return;
|
||||
|
||||
gsap.from('.box', {
|
||||
x: isDesktop ? 200 : 0,
|
||||
y: isMobile ? 100 : 0,
|
||||
opacity: 0,
|
||||
scrollTrigger: { trigger: '.box', start: 'top 80%' }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
```js
|
||||
// Guard all scroll animations with prefers-reduced-motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
if (!prefersReducedMotion.matches) {
|
||||
gsap.from('.box', {
|
||||
opacity: 0, y: 50,
|
||||
scrollTrigger: { trigger: '.box', start: 'top 85%' }
|
||||
});
|
||||
} else {
|
||||
// Show element immediately, no animation
|
||||
gsap.set('.box', { opacity: 1, y: 0 });
|
||||
}
|
||||
```
|
||||
|
||||
Or use `gsap.matchMedia()` with `prefers-reduced-motion: reduce` condition (see above).
|
||||
|
||||
---
|
||||
|
||||
## Performance & Cleanup
|
||||
|
||||
```js
|
||||
// Kill a specific trigger
|
||||
const st = ScrollTrigger.create({ ... });
|
||||
st.kill();
|
||||
|
||||
// Kill all triggers (e.g., on page transition)
|
||||
ScrollTrigger.killAll();
|
||||
|
||||
// Refresh all trigger positions (after dynamic content loads)
|
||||
ScrollTrigger.refresh();
|
||||
```
|
||||
|
||||
**Performance rules:**
|
||||
- Only animate `transform` and `opacity` — GPU-accelerated, no layout recalculation
|
||||
- Avoid animating `width`, `height`, `top`, `left`, `box-shadow`, `filter`
|
||||
- Use `ScrollTrigger.batch()` for many similar elements — far better than one trigger per element
|
||||
- Add `will-change: transform` sparingly — only on actively animating elements
|
||||
- Always remove `markers: true` before production
|
||||
|
||||
---
|
||||
|
||||
## Common Copilot Pitfalls
|
||||
|
||||
**Forgot registerPlugin:** Copilot often omits `gsap.registerPlugin(ScrollTrigger)`.
|
||||
Always add it before any ScrollTrigger usage.
|
||||
|
||||
**Wrong ease for scrub:** Copilot defaults to `power2.out` even on scrub animations.
|
||||
Always use `ease: 'none'` when `scrub: true` or `scrub: number`.
|
||||
|
||||
**useEffect instead of useGSAP in React:** Copilot generates `useEffect` — always swap to `useGSAP`.
|
||||
|
||||
**Static end value for horizontal scroll:** Copilot writes `end: "+=" + container.offsetWidth`.
|
||||
Correct: `end: () => "+=" + container.offsetWidth` (function form recalculates on resize).
|
||||
|
||||
**markers left in production:** Copilot adds `markers: true` and leaves it. Always remove.
|
||||
|
||||
**Scrub without pin on long animations:** Scrubbing a long timeline without pinning means
|
||||
the element scrolls out of view. Add `pin: true` or shorten the scroll distance.
|
||||
Reference in New Issue
Block a user