Cinematic Gsap Lenis Motion System
Meng ToCommunityCreated by Meng To
- Skill
- Tool integration
- Software development
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured approach to implementing premium, editorial-style motion on the web. It focuses on subtle, intentional interactions—such as smooth scrolling, staggered text reveals, parallax effects, and magnetic hover states—designed to create a cohesive and polished user experience. The system emphasizes performance and accessibility, offering a modular markup API and clear choreography rules to ensure animations enhance rather than distract from the content.
What it does
- Integrates Lenis for smooth scrolling synced with GSAP ScrollTrigger.
- Provides a declarative markup API for text reveals, parallax, and magnetic interactions.
- Includes pre-configured motion tokens for consistent easing and timing.
- Implements performance-conscious animation patterns using transform and opacity.
- Handles accessibility requirements like reduced-motion preferences and touch-device detection.
- Offers a structured choreography guide for hero sections and scroll-based storytelling.
When to use it
- When building luxury editorial websites or creative portfolios.
- When you need a unified motion language across a complex site.
- When aiming for high-polish, Awwwards-style interactive experiences.
- When you need to implement scroll-synced storytelling or pinned sections.
Advantages
- Ensures consistent motion quality through standardized easing and timing tokens.
- Reduces boilerplate code with a reusable data-attribute-based markup system.
- Prioritizes performance by using hardware-accelerated properties.
- Built-in support for accessibility and graceful degradation for non-JS environments.
- Provides clear architectural patterns for managing complex scroll-based animations.
Usage examples
- “How do I implement a staggered text reveal for my hero section?”
- The agent will guide you to add the 'data-motion-text' attribute to your heading and call the 'initTextReveals' function, which automatically splits text into words or lines and applies the configured GSAP animation.
- “How can I add a magnetic hover effect to my navigation buttons?”
- The agent will instruct you to add the 'data-magnetic' attribute to your button elements and initialize the 'initMagnetic' function to handle the pointer-reactive movement.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/mengto/cinematic-gsap-lenis-motion-system/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip cinematic-gsap-lenis-motion-system.zip -d .agents/skills
Documentation by Meng To
Cinematic GSAP Lenis Motion System
Use When
The site needs a full premium motion language, not one isolated animation.
Smooth scrolling, scroll reveals, pinned scenes, parallax, hover motion, and cursor behavior should feel connected.
The target feel is luxury editorial, Apple-level polish, creative studio portfolio, or immersive cinematic storytelling.
The stack can use GSAP, ScrollTrigger, and Lenis.
Motion Taste
Smooth, elegant, slightly delayed, and intentional.
Staggered motion should guide reading order.
Layered movement should create depth without making the interface feel busy.
ScrollTrigger should start scenes when they enter the viewport, not react to every tiny scroll.
Prefer subtlety over intensity.
Avoid:
Bounce, elastic, springy, or playful motion.
Fast abrupt transitions.
Large scale jumps.
Over-animated UI.
Flashy gaming-style effects.
Base Tokens
Eases:
power3.out,power4.out,expo.out.Scroll scrub:
scrub: 0.8to1.4for cinematic delay.Reveals:
0.75sto1.1s.Hover:
0.35sto0.6s.Cursor lag:
0.25sto0.45s.Text stagger: words
0.035sto0.07s, lines0.08sto0.14s.Card stagger:
0.06sto0.1s.Reveal trigger:
start: "top 82%".Pin handoff:
anticipatePin: 1.
Setup
Install:
npm i gsap lenisInitialize once, after the DOM exists. Lenis drives its RAF through the GSAP ticker so ScrollTrigger and smooth scroll stay synced.
import Lenis from "lenis";
import "lenis/dist/lenis.css";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
gsap.defaults({ ease: "power3.out", duration: 0.85 });
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let lenis;
if (!reduceMotion) {
lenis = new Lenis({
lerp: 0.08,
smoothWheel: true,
wheelMultiplier: 0.9,
anchors: true,
});
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
}
window.addEventListener("load", () => {
ScrollTrigger.refresh();
});Markup API
Use small data attributes so the motion system can be reused across pages.
<h1 data-motion-text="lines">Digital products with cinematic restraint.</h1>
<p data-motion-text="words">Every interaction should feel deliberate.</p>
<section data-reveal-group>
<article data-reveal="fade-up" data-reveal-item>...</article>
<article data-reveal="fade-up" data-reveal-item>...</article>
</section>
<figure data-image-reveal data-parallax-section>
<img data-parallax-image src="/studio.jpg" alt="">
</figure>
<a data-magnetic data-cursor-label="Explore" href="/work">Explore</a>
<div data-cursor><span data-cursor-label></span></div>CSS Foundation
html.has-motion [data-motion-text],
html.has-motion [data-reveal],
html.has-motion [data-reveal-item],
html.has-motion [data-image-reveal] {
visibility: hidden;
}
.motion-line-mask,
.motion-word-mask {
display: inline-block;
overflow: hidden;
vertical-align: top;
}
.motion-line,
.motion-word {
display: inline-block;
will-change: transform, opacity, filter;
}
[data-image-reveal] {
overflow: hidden;
}
[data-parallax-image] {
display: block;
width: 100%;
height: 115%;
object-fit: cover;
will-change: transform;
}
[data-cursor] {
position: fixed;
left: 0;
top: 0;
z-index: 9999;
pointer-events: none;
mix-blend-mode: difference;
transform: translate3d(-50%, -50%, 0);
will-change: transform;
}
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
[data-cursor] {
display: none;
}
}Staggered Text Reveals
Use masked containers for premium text. Prefer manual line wrappers when exact line breaks matter. Use word splitting for flexible responsive text.
document.documentElement.classList.add("has-motion");
function splitWords(element) {
if (element.dataset.motionSplit === "true") return;
const text = element.textContent || "";
const parts = text.split(/(\s+)/);
element.textContent = "";
element.setAttribute("aria-label", text.trim());
let index = 0;
parts.forEach((part) => {
if (!part.trim()) {
element.appendChild(document.createTextNode(part));
return;
}
const mask = document.createElement("span");
const word = document.createElement("span");
mask.className = "motion-word-mask";
mask.setAttribute("aria-hidden", "true");
word.className = "motion-word";
word.textContent = part;
word.style.setProperty("--word-index", index);
mask.appendChild(word);
element.appendChild(mask);
index += 1;
});
element.dataset.motionSplit = "true";
}
function splitLines(element) {
if (element.dataset.motionLineSplit === "true") return;
if (element.querySelector(".motion-line")) return;
const text = (element.textContent || "").trim();
const lines = text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
if (lines.length < 2) return;
element.textContent = "";
element.setAttribute("aria-label", text);
lines.forEach((line) => {
const mask = document.createElement("span");
const inner = document.createElement("span");
mask.className = "motion-line-mask";
mask.setAttribute("aria-hidden", "true");
inner.className = "motion-line";
inner.textContent = line;
mask.appendChild(inner);
element.appendChild(mask);
element.appendChild(document.createTextNode(" "));
});
element.dataset.motionLineSplit = "true";
}
function initTextReveals() {
if (reduceMotion) {
gsap.set("[data-motion-text]", { autoAlpha: 1, clearProps: "all" });
return;
}
gsap.utils.toArray("[data-motion-text='words']").forEach((element) => {
splitWords(element);
const words = element.querySelectorAll(".motion-word");
gsap.set(element, { autoAlpha: 1 });
gsap.fromTo(
words,
{ yPercent: 110, autoAlpha: 0, filter: "blur(8px)" },
{
yPercent: 0,
autoAlpha: 1,
filter: "blur(0px)",
duration: 0.9,
ease: "power4.out",
stagger: 0.055,
scrollTrigger: {
trigger: element,
start: "top 82%",
once: true,
},
}
);
});
gsap.utils.toArray("[data-motion-text='lines']").forEach((element) => {
splitLines(element);
const lines = element.querySelectorAll(".motion-line");
const targets = lines.length ? lines : element.children;
gsap.set(element, { autoAlpha: 1 });
gsap.fromTo(
targets,
{ yPercent: 100, autoAlpha: 0, filter: "blur(8px)" },
{
yPercent: 0,
autoAlpha: 1,
filter: "blur(0px)",
duration: 1,
ease: "power4.out",
stagger: 0.11,
scrollTrigger: {
trigger: element,
start: "top 84%",
once: true,
},
}
);
});
}Line markup when exact line breaks matter:
<h2 data-motion-text="lines">
<span class="motion-line-mask"><span class="motion-line">Cinematic motion</span></span>
<span class="motion-line-mask"><span class="motion-line">with editorial restraint.</span></span>
</h2>Scroll Reveals
Create a small reveal preset map. Use autoAlpha, transforms, and light blur. Use blur sparingly on large elements.
const revealPresets = {
"fade-up": { from: { y: 32, autoAlpha: 0 }, to: { y: 0, autoAlpha: 1 } },
"blur-in": { from: { y: 18, autoAlpha: 0, filter: "blur(10px)" }, to: { y: 0, autoAlpha: 1, filter: "blur(0px)" } },
"scale": { from: { scale: 0.96, autoAlpha: 0 }, to: { scale: 1, autoAlpha: 1 } },
"slide-left": { from: { x: 48, autoAlpha: 0 }, to: { x: 0, autoAlpha: 1 } },
"slide-right": { from: { x: -48, autoAlpha: 0 }, to: { x: 0, autoAlpha: 1 } },
};
function initScrollReveals() {
if (reduceMotion) {
gsap.set("[data-reveal], [data-reveal-item]", { autoAlpha: 1, clearProps: "all" });
return;
}
gsap.utils.toArray("[data-reveal-group]").forEach((group) => {
const items = group.querySelectorAll("[data-reveal-item]");
gsap.set(group, { autoAlpha: 1 });
gsap.fromTo(
items,
{ y: 36, autoAlpha: 0, filter: "blur(8px)" },
{
y: 0,
autoAlpha: 1,
filter: "blur(0px)",
duration: 0.95,
ease: "power4.out",
stagger: 0.075,
scrollTrigger: {
trigger: group,
start: "top 82%",
once: true,
},
}
);
});
gsap.utils.toArray("[data-reveal]:not([data-reveal-item])").forEach((element) => {
const preset = revealPresets[element.dataset.reveal] || revealPresets["fade-up"];
gsap.set(element, { autoAlpha: 1 });
gsap.fromTo(element, preset.from, {
...preset.to,
duration: 0.9,
ease: "power4.out",
delay: Number(element.dataset.revealDelay || 0),
scrollTrigger: {
trigger: element,
start: "top 84%",
once: true,
},
});
});
}Clip Image Reveals
function initImageReveals() {
if (reduceMotion) {
gsap.set("[data-image-reveal]", { autoAlpha: 1, clipPath: "none" });
return;
}
gsap.utils.toArray("[data-image-reveal]").forEach((figure) => {
const image = figure.querySelector("img");
gsap.set(figure, { autoAlpha: 1 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: figure,
start: "top 82%",
once: true,
},
});
tl.fromTo(
figure,
{ clipPath: "inset(0 0 100% 0)" },
{ clipPath: "inset(0 0 0% 0)", duration: 1.1, ease: "power4.out" }
).fromTo(
image,
{ scale: 1.08, autoAlpha: 0.75 },
{ scale: 1, autoAlpha: 1, duration: 1.2, ease: "power4.out" },
0
);
});
}Parallax Motion
Use speed differences instead of dramatic movement. Backgrounds move slower than content. Foreground accents move slightly faster.
function initParallax() {
if (reduceMotion) return;
gsap.utils.toArray("[data-parallax-image], [data-parallax-layer]").forEach((layer) => {
const speed = Number(layer.dataset.parallaxSpeed || 0.18);
const section = layer.closest("[data-parallax-section]") || layer;
gsap.to(layer, {
y: () => window.innerHeight * speed * -1,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top bottom",
end: "bottom top",
scrub: 1.2,
invalidateOnRefresh: true,
},
});
});
}Pinned Scroll Sections
Use pinned sections for story moments only. Keep scroll-synced movement linear, then layer eased reveal tweens inside the scene.
function initHorizontalGalleries() {
if (reduceMotion) return;
gsap.utils.toArray("[data-horizontal-gallery]").forEach((section) => {
const track = section.querySelector("[data-horizontal-track]");
if (!track) return;
gsap.to(track, {
x: () => -(track.scrollWidth - window.innerWidth),
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: () => `+=${track.scrollWidth}`,
scrub: 1,
pin: true,
anticipatePin: 1,
invalidateOnRefresh: true,
},
});
});
}Sticky storytelling pattern:
function initStoryScenes() {
if (reduceMotion) return;
gsap.utils.toArray("[data-story-scene]").forEach((scene) => {
const panels = scene.querySelectorAll("[data-story-panel]");
gsap.timeline({
scrollTrigger: {
trigger: scene,
start: "top top",
end: () => `+=${panels.length * window.innerHeight}`,
scrub: 1.1,
pin: true,
anticipatePin: 1,
},
})
.to(panels, { yPercent: -100 * (panels.length - 1), ease: "none" })
.to(scene.querySelectorAll("[data-story-depth]"), { yPercent: -16, ease: "none" }, 0);
});
}Premium Hover Interactions
Use GSAP quickTo for magnetic motion so hover follows the pointer without re-creating tweens on every event.
function initMagnetic() {
if (reduceMotion || window.matchMedia("(pointer: coarse)").matches) return;
gsap.utils.toArray("[data-magnetic]").forEach((element) => {
const strength = Number(element.dataset.magnetic || 0.18);
const xTo = gsap.quickTo(element, "x", { duration: 0.45, ease: "power3.out" });
const yTo = gsap.quickTo(element, "y", { duration: 0.45, ease: "power3.out" });
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
const x = (event.clientX - rect.left - rect.width / 2) * strength;
const y = (event.clientY - rect.top - rect.height / 2) * strength;
xTo(x);
yTo(y);
});
element.addEventListener("pointerleave", () => {
xTo(0);
yTo(0);
});
});
}Hover recipes:
Magnetic buttons: translate
x/yonly, keep scale under1.03.Magnetic cards: add
rotateX/rotateYunder4deg.Image zoom:
scale: 1to1.06, duration0.7s, easepower3.out.Grayscale to color: transition filter only on small/medium media.
Animated arrows: move icon
x: 0tox: 6, fade the duplicate arrow in.Directional hover: calculate pointer entry side, but keep movement under
16px.
Custom Cursor
Use a cursor follower as atmosphere, not decoration. Hide it on touch devices.
function initCursor() {
if (reduceMotion || window.matchMedia("(pointer: coarse)").matches) return;
const cursor = document.querySelector("[data-cursor]");
if (!cursor) return;
const label = cursor.querySelector("[data-cursor-label]");
const xTo = gsap.quickTo(cursor, "x", { duration: 0.35, ease: "power3.out" });
const yTo = gsap.quickTo(cursor, "y", { duration: 0.35, ease: "power3.out" });
document.addEventListener("pointermove", (event) => {
xTo(event.clientX);
yTo(event.clientY);
});
gsap.utils.toArray("[data-cursor-label]")
.filter((target) => !cursor.contains(target))
.forEach((target) => {
target.addEventListener("pointerenter", () => {
if (label) label.textContent = target.dataset.cursorLabel || "";
gsap.to(cursor, { scale: 1.75, duration: 0.35, ease: "power3.out" });
});
target.addEventListener("pointerleave", () => {
if (label) label.textContent = "";
gsap.to(cursor, { scale: 1, duration: 0.35, ease: "power3.out" });
});
});
}Mouse-Reactive Layers
Use one pointer listener per section. Depth should be barely visible.
function initMouseParallax() {
if (reduceMotion || window.matchMedia("(pointer: coarse)").matches) return;
gsap.utils.toArray("[data-mouse-parallax]").forEach((section) => {
const layers = section.querySelectorAll("[data-mouse-depth]");
const setters = Array.from(layers).map((layer) => ({
layer,
depth: Number(layer.dataset.mouseDepth || 0.04),
xTo: gsap.quickTo(layer, "x", { duration: 0.8, ease: "power3.out" }),
yTo: gsap.quickTo(layer, "y", { duration: 0.8, ease: "power3.out" }),
}));
section.addEventListener("pointermove", (event) => {
const rect = section.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
setters.forEach(({ depth, xTo, yTo }) => {
xTo(x * depth);
yTo(y * depth);
});
});
section.addEventListener("pointerleave", () => {
setters.forEach(({ xTo, yTo }) => {
xTo(0);
yTo(0);
});
});
});
}Choreography Rules
Hero: background or media starts first, headline lines second, supporting copy third, CTA last.
Sections: label first, heading second, media third, cards/details last.
Pinned scenes: one idea per viewport. Avoid stacking too many simultaneous transforms.
Parallax: background slower, foreground slightly faster, text mostly stable.
Cursor and hover effects should support navigation intent, not fight it.
Performance Rules
Animate
transform,opacity, and short-livedclip-path.Use
filter: blur()only on text or small elements.Keep pinned sections limited and test them on mobile.
Add
will-changeonly to elements that actually animate.Use
ScrollTrigger.refresh()after images, fonts, or layout shifts.In React or SPA routes, wrap setup in
gsap.context()and callctx.revert()on cleanup.Kill or revert ScrollTriggers on page transitions before initializing the next route.
Init Order
initTextReveals();
initScrollReveals();
initImageReveals();
initParallax();
initHorizontalGalleries();
initStoryScenes();
initMagnetic();
initCursor();
initMouseParallax();
ScrollTrigger.refresh();QA Checklist
Text and content remain visible with JavaScript disabled.
Reduced-motion users get static content and no smooth-scroll hijacking.
Scroll reveals animate once unless the design explicitly asks for replay.
Pinned sections do not overlap the next section.
Hover and cursor interactions are disabled on touch.
No layout properties are animated during scroll.
The page still feels readable if all decorative motion is removed.
Files
- ATTRIBUTION.mdAdded by AutoGineer890 B
- LICENSELicence file added by AutoGineer1.0 KiB
- REFERENCES.md165 B
- SKILL.md17.2 KiB
- demo/PROMPT.md2.1 KiB
- demo/index.html16.2 KiB
- demo/preview.jpg72.3 KiB
demo/PROMPT.md
Cinematic GSAP Lenis Motion System Demo Prompts
Minimal prompt
Use $cinematic-gsap-lenis-motion-system to create a polished standalone HTML example that clearly demonstrates the skill.
Recreate the demo
Use $cinematic-gsap-lenis-motion-system to build a responsive reference demo for this capability:
Create premium cinematic web motion systems with GSAP, ScrollTrigger, and Lenis. Use for luxury editorial websites, creative studio portfolios, Awwwards-style interactions, smooth scroll reveals, staggered text, parallax, pinned sections, magnetic hover states, custom cursors, and mouse-reactive layered movement.
Direction
Make choreography, easing, and replay behavior the primary demonstration.
Use an art-directed composition with one clear focal point, restrained supporting copy, and enough surrounding interface to show how the technique behaves in a real product or landing page.
Canonical example
Reveal the lines Motion should, explain the, and next state in sequence.
Use a four-part timeline and a keyboard-accessible Replay sequence control.
Deliverable
Create demo/index.html as a standalone document.
Keep CSS and JavaScript inline.
Put any required images, fonts, models, textures, or vendor files in demo/assets/.
Do not add a framework, package manager, build step, or node_modules.
Add controls only when they help inspect or replay the technique.
Acceptance checks
Keep the demo responsive from 390px through 1440px.
Use semantic HTML, visible focus states, and reduced-motion handling.
Keep all HTML, CSS, and JavaScript inside demo/index.html.
Use only relative local asset paths.
Provide a replay control and stop continuous work when the animation settles.
Remove nonessential movement under prefers-reduced-motion.
Remix prompt
Use $cinematic-gsap-lenis-motion-system and keep the same implementation contract, but change the subject, copy, palette, and content hierarchy. Preserve the core technique, accessibility behavior, responsive rules, and performance constraints demonstrated by the reference.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Meng To
- Publisher
- Meng To (Community)
- Source
- MengTo/Skills · agent-skills/web-design/cinematic-gsap-lenis-motion-system · commit 5ea1661
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Meng To or Meng To.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download cinematic-gsap-lenis-motion-system.zip