Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/mengto-cinematic-gsap-lenis-motion-system/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-16 | ✗→✓ | ▲ Improved | 216% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 193% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 99% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 154% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 197% | 0% |
Avoid:
power3.out, power4.out, expo.out.scrub: 0.8 to 1.4 for cinematic delay.0.75s to 1.1s.0.35s to 0.6s.0.25s to 0.45s.0.035s to 0.07s, lines 0.08s to 0.14s.0.06s to 0.1s.start: "top 82%".anticipatePin: 1.Install:
bashnpm i gsap lenis
Initialize once, after the DOM exists. Lenis drives its RAF through the GSAP ticker so ScrollTrigger and smooth scroll stay synced.
jsimport 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(); });
Use small data attributes so the motion system can be reused across pages.
html<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>
csshtml.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; } }
Use masked containers for premium text. Prefer manual line wrappers when exact line breaks matter. Use word splitting for flexible responsive text.
jsdocument.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:
html<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>
Create a small reveal preset map. Use autoAlpha, transforms, and light blur. Use blur sparingly on large elements.
jsconst 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, }, }); }); }
jsfunction 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 ); }); }
Use speed differences instead of dramatic movement. Backgrounds move slower than content. Foreground accents move slightly faster.
jsfunction 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, }, }); }); }
Use pinned sections for story moments only. Keep scroll-synced movement linear, then layer eased reveal tweens inside the scene.
jsfunction 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:
jsfunction 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); }); }
Use GSAP quickTo for magnetic motion so hover follows the pointer without re-creating tweens on every event.
jsfunction 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:
x/y only, keep scale under 1.03.rotateX/rotateY under 4deg.scale: 1 to 1.06, duration 0.7s, ease power3.out.x: 0 to x: 6, fade the duplicate arrow in.16px.Use a cursor follower as atmosphere, not decoration. Hide it on touch devices.
jsfunction 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" }); }); }); }
Use one pointer listener per section. Depth should be barely visible.
jsfunction 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); }); }); }); }
transform, opacity, and short-lived clip-path.filter: blur() only on text or small elements.will-change only to elements that actually animate.ScrollTrigger.refresh() after images, fonts, or layout shifts.gsap.context() and call ctx.revert() on cleanup.jsinitTextReveals(); initScrollReveals(); initImageReveals(); initParallax(); initHorizontalGalleries(); initStoryScenes(); initMagnetic(); initCursor(); initMouseParallax(); ScrollTrigger.refresh();
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-16 | fail→pass | 11,524 | 6,414 | -44% | 1 | 1 | 0% | 2,095 | 6,622 | +216% | 0 | 0 | — |
case-01 | fail→pass | 11,464 | 6,768 | -41% | 1 | 1 | 0% | 2,329 | 6,824 | +193% | 0 | 0 | — |
case-02 | fail→pass | 22,416 | 16,855 | -25% | 1 | 1 | 0% | 4,782 | 9,494 | +99% | 0 | 0 | — |
case-03 | pass→pass | 21,669 | 15,011 | -31% | 1 | 1 | 0% | 4,974 | 8,764 | +76% | 0 | 0 | — |
case-04 | fail→pass | 15,763 | 12,355 | -22% | 1 | 1 | 0% | 3,096 | 7,855 | +154% | 0 | 0 | — |
case-05 | pass→pass | 14,607 | 11,230 | -23% | 1 | 1 | 0% | 2,765 | 7,688 | +178% | 0 | 0 | — |
case-06 | fail→pass | 11,379 | 5,432 | -52% | 1 | 1 | 0% | 2,117 | 6,297 | +197% | 0 | 0 | — |
case-07 | fail→pass | 11,689 | 8,102 | -31% | 1 | 1 | 0% | 2,175 | 6,938 | +219% | 0 | 0 | — |
case-08 | pass→pass | 13,989 | 13,560 | -3% | 1 | 1 | 0% | 2,629 | 7,893 | +200% | 0 | 0 | — |
case-09 | fail→pass | 15,054 | 12,114 | -20% | 1 | 1 | 0% | 2,614 | 7,555 | +189% | 0 | 0 | — |
case-10 | pass→pass | 12,801 | 10,915 | -15% | 1 | 1 | 0% | 2,351 | 7,389 | +214% | 0 | 0 | — |
case-11 | fail→fail | 12,490 | 13,086 | +5% | 1 | 1 | 0% | 2,571 | 7,996 | +211% | 0 | 0 | — |
case-12 | pass→pass | 15,406 | 5,238 | -66% | 1 | 1 | 0% | 2,902 | 6,355 | +119% | 0 | 0 | — |
case-13 | pass→pass | 13,198 | 14,500 | +10% | 1 | 1 | 0% | 2,534 | 8,153 | +222% | 0 | 0 | — |
case-14 | pass→pass | 11,057 | 7,377 | -33% | 1 | 1 | 0% | 2,127 | 6,607 | +211% | 0 | 0 | — |
case-15 | pass→pass | 13,694 | 12,310 | -10% | 1 | 1 | 0% | 2,746 | 7,664 | +179% | 0 | 0 | — |
case-17 | fail→fail | 17,430 | 17,800 | +2% | 1 | 1 | 0% | 3,058 | 8,871 | +190% | 0 | 0 | — |
case-18 | fail→fail | 12,146 | 9,465 | -22% | 1 | 1 | 0% | 2,317 | 7,087 | +206% | 0 | 0 | — |
case-19 | fail→fail | 13,174 | 6,980 | -47% | 1 | 1 | 0% | 2,417 | 6,613 | +174% | 0 | 0 | — |
case-20 | pass→pass | 12,600 | 19,399 | +54% | 1 | 1 | 0% | 2,638 | 9,788 | +271% | 0 | 0 | — |
case-21 | pass→pass | 12,528 | 13,664 | +9% | 1 | 1 | 0% | 2,570 | 8,123 | +216% | 0 | 0 | — |
case-22 | pass→pass | 20,484 | 21,039 | +3% | 1 | 1 | 0% | 4,252 | 10,095 | +137% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.