skills/web-design/scroll-gsap-engine/SKILL.md
GSAP ScrollTrigger master skill for building award-winning scroll-driven websites. Covers GSAP ScrollTrigger, Lenis smooth scroll, CSS scroll-driven animations, pinned sections, horizontal scroll, parallax, image sequences, text split reveals, and section transitions. Enforces performance rules, accessibility, and mobile-first architecture. Use when: GSAP, ScrollTrigger, Lenis, pinned scroll, scroll hijack, image sequence, SplitText, cinematic website, Apple-style scroll, dreamy scroll, scroll animation with GSAP. For React-only scroll with Framer Motion, use scroll-motion skill instead.
npx skillsauth add michailbul/laniameda-skills scroll-gsap-engineInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You build scroll-driven websites that feel like cinematic experiences — not just pages with animations bolted on. You treat scrolling as a narrative device. Every transition has purpose. Every animation serves the story.
Your standard: Awwwards Site of the Year quality. If it doesn't feel like a controlled movie, it's not done.
Choose tools based on the complexity of the scroll experience:
| Complexity | Smooth Scroll | Scroll Engine | 3D | Text Effects |
|---|---|---|---|---|
| Simple (reveals, progress bars) | CSS scroll-behavior: smooth | CSS animation-timeline: view() | None | CSS transitions |
| Medium (parallax, sticky sections) | Lenis | GSAP ScrollTrigger | None | GSAP SplitText |
| Complex (pinned timelines, horizontal scroll, image sequences) | Lenis | GSAP ScrollTrigger + timeline | Optional Three.js | GSAP SplitText |
| Cinematic (3D camera, shader effects, frame scrubbing) | Lenis | GSAP ScrollTrigger | Three.js / Theatre.js | GSAP SplitText |
Default stack for most projects: Lenis + GSAP ScrollTrigger + SplitText. This covers 90% of scroll experiences.
React-only alternative: Use the scroll-motion skill for Framer Motion / Motion-based scroll experiences.
Every scroll section follows this pattern: tall outer wrapper (provides scroll distance) + sticky inner viewport (provides the visual frame).
<!-- Section height = 100vh * (phases + 1) -->
<section class="scroll-section" style="height: 400vh;">
<div class="viewport" style="position: sticky; top: 0; height: 100vh; overflow: hidden;">
<!-- All animated content lives inside the viewport -->
<div class="phase phase-1">...</div>
<div class="phase phase-2">...</div>
<div class="phase phase-3">...</div>
</div>
</section>
Height calculation rule: section height = 100vh * (number_of_content_phases + 1)
400vh600vh// app/providers/SmoothScroll.tsx
'use client';
import { useEffect, useRef } from 'react';
import Lenis from 'lenis';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export function SmoothScrollProvider({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<Lenis | null>(null);
useEffect(() => {
const lenis = new Lenis({ autoRaf: false });
lenisRef.current = lenis;
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
return () => {
lenis.destroy();
ScrollTrigger.getAll().forEach(t => t.kill());
};
}, []);
return <>{children}</>;
}
Use useGSAP() from @gsap/react instead of raw useEffect/useLayoutEffect. It handles cleanup automatically and scopes selectors to the container.
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(useGSAP, ScrollTrigger);
function ScrollSection() {
const containerRef = useRef<HTMLDivElement>(null);
useGSAP(() => {
// Selectors like '.element' are automatically scoped to containerRef
gsap.to('.element', {
scrollTrigger: { trigger: '.section', scrub: 1 },
y: -100
});
}, { scope: containerRef }); // scope = auto-cleanup + scoped selectors
// For event handlers created outside the hook, use contextSafe:
const { contextSafe } = useGSAP({ scope: containerRef });
const handleClick = contextSafe(() => {
gsap.to('.element', { rotation: 360 });
});
return <div ref={containerRef}>...</div>;
}
Fallback (no @gsap/react): Use gsap.context() manually:
useLayoutEffect(() => {
const ctx = gsap.context(() => {
// All GSAP/ScrollTrigger code here
}, containerRef);
return () => ctx.revert();
}, []);
SSR rule: Never run GSAP during server-side rendering. The useGSAP hook handles this automatically.
These are the rules that separate working code from broken scroll experiences:
gsap.registerPlugin(ScrollTrigger);
// Do this ONCE. Not in components. Not in effects.
// CORRECT
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.section',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
pin: true
}
});
tl.to('.box1', { x: 200 })
.to('.box2', { scale: 1.5 }, '<'); // simultaneous with previous
// WRONG — never put scrollTrigger on a child tween inside a timeline
tl.to('.box', { x: 100, scrollTrigger: {...} }); // BREAKS
They are mutually exclusive approaches. Pick one:
scrub = animation progress tied to scroll position (cinematic)toggleActions = animation plays/reverses at trigger points (event-based)// CORRECT — pin the section, animate its children
gsap.timeline({
scrollTrigger: { trigger: '.section', pin: true, scrub: 1 }
})
.to('.section .child', { x: 200 });
// WRONG — animating the pinned element
gsap.to('.section', {
x: 200,
scrollTrigger: { trigger: '.section', pin: true } // BREAKS
});
Or use refreshPriority to override. Call ScrollTrigger.refresh() after:
Individual triggers for each card/item = performance disaster:
// CORRECT — batch for many elements
ScrollTrigger.batch('.card', {
onEnter: (elements) => {
gsap.to(elements, {
opacity: 1, y: 0, duration: 0.6,
stagger: 0.15, ease: 'power2.out'
});
},
start: 'top 90%',
once: true
});
// WRONG — individual triggers for 50 cards
document.querySelectorAll('.card').forEach(card => {
gsap.to(card, { scrollTrigger: { trigger: card } }); // 50 ScrollTriggers!
});
// In cleanup
ScrollTrigger.getAll().forEach(t => t.kill());
// Or better: use useGSAP() / gsap.context() which handles this automatically
// BETTER — autoAlpha sets visibility:hidden when opacity reaches 0
// This removes the element from accessibility tree and click targets
gsap.from('.element', { autoAlpha: 0, duration: 0.6 });
// Instead of just opacity which leaves an invisible but interactive element
gsap.from('.element', { opacity: 0, duration: 0.6 }); // still clickable at 0!
scrollTrigger: {
markers: true // DEBUG ONLY — remove before shipping
}
The most common award-winning pattern. Section stays fixed, content phases in/out.
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.pinned-section',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
pin: false // using CSS sticky instead for simpler DOM
}
});
tl.to('.phase-1', { opacity: 0, y: -50, duration: 1 })
.from('.phase-2', { opacity: 0, y: 50, duration: 1 })
.to('.phase-2', { opacity: 0, y: -50, duration: 1 })
.from('.phase-3', { opacity: 0, y: 50, duration: 1 });
When to use: Product feature walkthroughs, storytelling sections, before/after reveals.
Vertical scrolling converts to horizontal panel movement.
const panels = gsap.utils.toArray('.panel');
gsap.to(panels, {
xPercent: -100 * (panels.length - 1),
ease: 'none',
scrollTrigger: {
trigger: '.horizontal-container',
pin: true,
scrub: 1,
snap: 1 / (panels.length - 1),
end: () => '+=' + document.querySelector('.horizontal-container').offsetWidth
}
});
When to use: Image galleries, portfolio showcases, step-by-step processes. Mobile consideration: Add swipe affordance. Consider disabling horizontal scroll on mobile and stacking vertically instead.
Multiple layers moving at different speeds create depth.
document.querySelectorAll('.parallax-layer').forEach((layer) => {
const speed = parseFloat(layer.dataset.speed) || 0.5;
gsap.to(layer, {
y: () => window.innerHeight * speed,
ease: 'none',
scrollTrigger: {
trigger: layer.closest('.parallax-section'),
start: 'top bottom',
end: 'bottom top',
scrub: true
}
});
});
Layer speed guide: | Layer | Speed | Effect | |---|---|---| | Far background | 0.1-0.2 | Almost static, deep | | Mid background | 0.3-0.5 | Gentle movement | | Foreground content | 1.0 | Normal scroll speed | | Floating accents | 1.2-1.5 | Pops forward |
function imageSequence({ urls, canvas, scrollTrigger }) {
const ctx = canvas.getContext('2d');
const playhead = { frame: 0 };
const images = urls.map((url, i) => {
const img = new Image();
img.src = url;
if (i === 0) img.onload = () => ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return img;
});
return gsap.to(playhead, {
frame: images.length - 1,
ease: 'none',
snap: 'frame',
onUpdate: () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(images[Math.round(playhead.frame)], 0, 0, canvas.width, canvas.height);
},
scrollTrigger
});
}
// Usage
const frameCount = 148;
const urls = Array.from({ length: frameCount }, (_, i) =>
`/frames/frame_${String(i + 1).padStart(4, '0')}.webp`
);
imageSequence({
urls,
canvas: document.querySelector('#sequence-canvas'),
scrollTrigger: {
trigger: '#sequence-section',
start: 'top top',
end: '+=4000',
scrub: true,
pin: true
}
});
Performance rules for image sequences:
gsap.registerPlugin(SplitText, ScrollTrigger);
// Character reveal with mask (cinematic look)
const split = SplitText.create('.headline', {
type: 'chars',
mask: 'chars', // overflow:hidden wrappers for upward reveal
autoSplit: true // re-splits on resize
});
gsap.from(split.chars, {
y: '100%',
stagger: 0.03,
duration: 0.5,
ease: 'power2.out',
scrollTrigger: {
trigger: '.headline',
start: 'top 80%'
}
});
// Line-by-line body text reveal
const lineSplit = SplitText.create('.body-text', {
type: 'lines',
mask: 'lines',
autoSplit: true
});
gsap.from(lineSplit.lines, {
y: '100%',
stagger: 0.08,
duration: 0.6,
scrollTrigger: {
trigger: '.body-text',
start: 'top 80%'
}
});
Accessibility rule: Add aria-label to text elements BEFORE SplitText splits them. Screen readers need the original text.
const cards = gsap.utils.toArray('.stack-card');
cards.forEach((card, i) => {
ScrollTrigger.create({
trigger: card,
start: 'top 50%',
end: 'top 10%',
scrub: true,
animation: gsap.to(card, {
scale: 1 - (cards.length - i) * 0.05,
y: -30 * (cards.length - i)
})
});
});
const sections = gsap.utils.toArray('.themed-section');
sections.forEach((section) => {
const bg = section.dataset.bg;
const text = section.dataset.text;
ScrollTrigger.create({
trigger: section,
start: 'top center',
end: 'bottom center',
onEnter: () => gsap.to('body', { backgroundColor: bg, color: text, duration: 0.5 }),
onEnterBack: () => gsap.to('body', { backgroundColor: bg, color: text, duration: 0.5 })
});
});
gsap.from('.reveal-section', {
clipPath: 'inset(100% 0 0 0)', // hidden from bottom
scrollTrigger: {
trigger: '.reveal-section',
start: 'top 80%',
end: 'top 20%',
scrub: 1
}
});
// Circle reveal from center
gsap.from('.circle-reveal', {
clipPath: 'circle(0% at 50% 50%)',
scrollTrigger: {
trigger: '.circle-reveal',
start: 'top center',
end: 'bottom center',
scrub: true
}
});
For simple reveals and progress effects, native CSS is now viable and runs on the compositor thread (zero main thread cost).
@keyframes reveal {
from { opacity: 0; transform: translateY(50px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal-element {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: var(--accent);
transform-origin: left;
animation: scaleX linear;
animation-timeline: scroll();
}
@keyframes scaleX {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* Always wrap in @supports — Firefox still partial */
@supports (animation-timeline: view()) {
.element {
animation: fadeIn linear both;
animation-timeline: view();
animation-range: entry 25% cover 50%;
}
}
| Use CSS | Use GSAP | |---|---| | Simple fade/slide reveals | Multi-step timeline sequences | | Progress bars | Pinning sections | | Single-property parallax | Horizontal scroll conversion | | Performance-critical (compositor thread) | Snap behavior | | Progressive enhancement OK | Cross-browser consistency required | | No Firefox support needed | Image sequences on canvas |
gsap.matchMedia().add(
{
isDesktop: '(min-width: 1024px)',
isTablet: '(min-width: 768px) and (max-width: 1023px)',
isMobile: '(max-width: 767px)'
},
(context) => {
const { isDesktop, isMobile } = context.conditions;
if (isDesktop) {
// Full cinematic experience
gsap.timeline({
scrollTrigger: { trigger: '.section', pin: true, scrub: 1, end: '+=2000' }
})
.to('.content', { x: '-100vw' });
}
if (isMobile) {
// Simplified — no pinning, shorter animations
gsap.from('.content', {
opacity: 0, y: 30,
scrollTrigger: { trigger: '.content', start: 'top 80%' }
});
}
// Cleanup is automatic when breakpoint changes
}
);
scrub: 0.5 instead of scrub: 1 (touch scroll is more sensitive)| Property | Layout | Paint | Composite | Use? |
|---|---|---|---|---|
| transform (x, y, scale, rotate) | No | No | Yes | ALWAYS |
| opacity | No | No | Yes | ALWAYS |
| filter (blur, brightness) | No | Yes | No | Sparingly |
| clipPath | No | Yes | No | OK for reveals |
| width, height, top, left | Yes | Yes | No | NEVER animate |
| margin, padding | Yes | Yes | No | NEVER animate |
| background-color | No | Yes | No | OK for theme transitions |
/* Apply to elements ABOUT to animate — max 10-20 elements */
.will-animate {
will-change: transform, opacity;
}
/* NEVER apply globally */
* { will-change: transform; } /* GPU memory explosion */
// BAD — read/write interleaving forces layout recalculation
elements.forEach(el => {
const height = el.offsetHeight; // READ (forces layout)
el.style.transform = `translateY(${height}px)`; // WRITE
});
// GOOD — batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight); // All reads
elements.forEach((el, i) => {
el.style.transform = `translateY(${heights[i]}px)`; // All writes
});
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
initHeavyAnimation(entry.target);
observer.unobserve(entry.target);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('.heavy-section').forEach(el => observer.observe(el));
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// Show all content immediately, skip animations
gsap.set('.animated', { clearProps: 'all' });
// Do NOT initialize ScrollTrigger animations
} else {
initScrollAnimations();
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
aria-label on text elements BEFORE SplitText splits them<section>, <article>, heading hierarchyNever alter default scroll speed or direction globally. Users hate losing scroll control.
Acceptable exceptions:
Never acceptable:
gsap.context() in React — it handles cleanup automaticallyscrollTrigger: {
scrub: 1,
snap: {
snapTo: 1 / (sections.length - 1), // evenly spaced
duration: { min: 0.2, max: 0.5 },
delay: 0,
ease: 'power1.inOut'
}
}
// Or snap to specific positions
snap: [0, 0.25, 0.5, 0.75, 1]
// Or snap to labels
snap: 'labelsDirectional'
When to snap: Full-page sections, step-by-step walkthroughs, image sequence keyframes. When NOT to snap: Long-form content, editorial pages, anything where users need free-scroll control.
Format: "triggerPosition viewportPosition"
start: "top center" → trigger's top hits viewport center
start: "top 80%" → trigger's top hits 80% from viewport top
start: "center center" → trigger's center hits viewport center
end: "bottom top" → trigger's bottom hits viewport top
end: "+=2000" → 2000px after the start position
Visual: the first value is where on the trigger element, the second is where on the viewport.
| Ease | Feel | Use For |
|---|---|---|
| "none" | Linear, mechanical | Scrub animations (scroll-linked) |
| "power1.out" | Gentle deceleration | Subtle reveals |
| "power2.out" | Medium deceleration | Standard entrance animations |
| "power3.out" | Strong deceleration | Dramatic entrances |
| "back.out(1.7)" | Overshoot + settle | Playful, bouncy reveals |
| "elastic.out(1, 0.3)" | Spring bounce | Attention-grabbing elements |
| "expo.out" | Sharp deceleration | Hero text reveals |
Rule: Scrub animations should almost always use ease: "none" — the scroll IS the easing.
Before declaring a scroll experience complete:
prefers-reduced-motion: reduceScrollTrigger.getAll())gsap.matchMedia() used for mobile/tablet/desktop differencestransform and opacity used for scroll-linked movement<section>, <article>, heading hierarchy| Site | Pattern | Why Study It | |---|---|---| | Apple product pages | Image sequence scrubbing, pinned reveals | The benchmark for scroll-driven product storytelling | | Zentry (Awwwards SOTM) | Geometric transitions, video storytelling | Cinematic scroll done right | | Stripe Sessions | Clean scroll animations + light design | Purposeful, not overdone | | Frame.io | GSAP + React + Next.js long-scroll | Production-grade architecture |
Directories: Awwwards Scrolling, Godly Scrolling Animation, Made With GSAP
@supports fallback.#smooth-wrapper > #smooth-content), breaks with fixed positioning inside. Lenis works with native DOM. Default to Lenis.development
Seedance 2.0 video prompt director. Converts plain-text scene descriptions into production-ready bilingual EN+ZH video prompts optimized for the Seedance 2.0 video generator. Handles all Seedance work — action (combat, pursuit, stunts), general (landscapes, journeys, atmosphere), dialogue (confrontations, negotiations, interrogations), and non-narrative commercial work (ad spots, music videos, fashion films, automotive inserts, product shots, pet/character demos, cutaway montages, social reels for TikTok / Reels / YouTube Shorts). Use whenever the user wants to create a Seedance video prompt, mentions Seedance, or describes a cinematic scene for video generation. For NARRATIVE screenplay-integrated work, use seedance-screenwriter instead.
development
Write Seedance 2.0 prompts in screenplay format for narrative storytelling — when the prompts will be cut into a film, short, or scene. Use whenever you're generating shots that will be edited into a continuous story with dialogue, character beats, scene continuity, or coverage. Pairs with the screenwriter skill — read the scene's screenplay first (or the project's `scene.md` if it exists), then translate each shot into a Seedance prompt that reads as a screenplay page, not as an engineering spec.
documentation
Скилл-инструмент для сценариста полнометражного фильма или сериала. Используй всегда, когда пользователь хочет писать сценарий, поэпизодник, разрабатывать сцены, бит-шит, диалоги, делать ревизии, считать экранное время, резать длину, работать с персонажами или мифологией истории. Скилл работает на основе методологий Макки, Кэмпбелла и Аристотеля, выдаёт Hollywood-формат .docx, поддерживает билингвальные сценарии (диалог на одном языке + перевод в скобках под ним), и помогает аудитировать структуру по причинности и движению ценности. Скилл не привязан к конкретной истории — пользователь приносит свою.
development
Extract shot composition DNA from any car photograph into structured JSON — camera angle, lens, framing, lighting — stripped of car-specific details. Then reuse extracted angles with any car identity to generate new images at scale. Use when: extracting angles from reference photos, building a shot library, batch-analyzing car photography, replicating a great angle with a different car, running extraction pipelines in Freepik or Flora. Triggers: "extract this angle", "steal this composition", "shot DNA", "analyze this car photo", "replicate this shot with my car", "batch extract angles", "car photography analysis", "angle extraction", "build a shot library".