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
ATTRIBUTION.md
Attribution
This folder is an unmodified copy of the Agent Skill "cinematic-gsap-lenis-motion-system", distributed by AutoGineer (https://autogineer.com/agent-skills).
Commit: 5ea1661a0e05d82b054553daaa6baf6d0233c33c
Created by: Meng To (https://github.com/MengTo)
Publisher: Meng To (https://github.com/MengTo)
Licence: MIT License (https://github.com/MengTo/Skills/blob/5ea1661a0e05d82b054553daaa6baf6d0233c33c/LICENSE)
Licence evidence: repository licence LICENSE
Retrieved: 2026-11-07
The skill's own files are unmodified copies. AutoGineer is not affiliated with or endorsed by the author or the publisher.
Files added by AutoGineer
ATTRIBUTION.md: this file, written by AutoGineer
LICENSE: copied from LICENSE
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