Skip to main content
skill CX Codex

cinematic-gsap-lenis-motion-system — Claude Code skill: guide

Builds cinematic web animation systems using GSAP, ScrollTrigger, and Lenis. Ideal for luxury editorial layouts, creative portfolios, and smooth reveals.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add MengTo/Skills --skill cinematic-gsap-lenis-motion-system -a claude-code
Open on GitHub

What cinematic-gsap-lenis-motion-system does

The skill establishes a motion language tailored for high-end studio and editorial websites. It connects Lenis smooth scrolling directly into the GSAP ticker so scroll-driven scenes stay in sync. Timing tokens favor intentional delay: easing functions power3.out and power4.out, scrub values between 0.8s and 1.4s, and viewport triggers at top 82%. Bouncy, springy transitions are avoided entirely.

Animations bind to HTML markup via declarative data attributes like data-motion-text, data-reveal-group, and data-magnetic. Text is split into masked words or lines with subtle blur reveals. You must install gsap and lenis in your project dependencies, and the setup automatically respects prefers-reduced-motion settings.

What it helps with

  • sync Lenis smooth scrolling with GSAP ScrollTrigger via the global ticker
  • build masked text reveals with staggered words, lines, and blur filters
  • apply parallax movement to media sections with declarative data attributes
  • implement magnetic hover states and custom cursor tracking

Example prompts for the agent

  • Set up smooth scrolling and ticker synchronization using Lenis and GSAP
  • Create a staggered line reveal for the hero heading using data-motion-text
  • Add a magnetic hover effect to the explore link and configure a custom cursor

How to install cinematic-gsap-lenis-motion-system

1Skills CLI (Vercel)

npx skills add MengTo/Skills --skill cinematic-gsap-lenis-motion-system -a claude-code

2Manually: put the files in place

~/.claude/skills/cinematic-gsap-lenis-motion-system/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/cinematic-gsap-lenis-motion-system/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about cinematic-gsap-lenis-motion-system

How to install cinematic-gsap-lenis-motion-system in Claude Code?

Run npx skills add MengTo/Skills --skill cinematic-gsap-lenis-motion-system -a claude-code.

What does cinematic-gsap-lenis-motion-system do?

It provides your AI coding agent with guidelines and markup conventions for deliberate, cinematic animation using GSAP, ScrollTrigger, and Lenis.

What dependencies are required?

Your project requires gsap and lenis, installed via npm i gsap lenis.

Source text

SKILL.md · MIT
---
name: cinematic-gsap-lenis-motion-system
description: 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.
---

# 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.8` to `1.4` for cinematic delay.
- Reveals: `0.75s` to `1.1s`.
- Hover: `0.35s` to `0.6s`.
- Cursor lag: `0.25s` to `0.45s`.
- Text stagger: words `0.035s` to `0.07s`, lines `0.08s` to `0.14s`.
- Card stagger: `0.06s` to `0.1s`.
- Reveal trigger: `start: "top 82%"`.
- Pin handoff: `anticipatePin: 1`.

## Setup

Install:

```bash
npm i gsap lenis
```

Initialize once, after the DOM exists. Lenis drives its RAF through the GSAP ticker so ScrollTrigger and smooth scroll stay synced.

```js
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.

```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>
```

## CSS Foundation

```css
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.

```js
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:

```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>
```

## Scroll Reveals

Create a small reveal preset map. Use `autoAlpha`, transforms, and light blur. Use blur sparingly on large elements.

```js
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

```js
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.

```js
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.

```js
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:

```js
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.

```js
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/y` only, keep scale under `1.03`.
- Magnetic cards: add `rotateX/rotateY` under `4deg`.
- Image zoom: `scale: 1` to `1.06`, duration `0.7s`, ease `power3.out`.
- Grayscale to color: transition filter only on small/medium media.
- Animated arrows: move icon `x: 0` to `x: 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.

```js
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.

```js
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-lived `clip-path`.
- Use `filter: blur()` only on text or small elements.
- Keep pinned sections limited and test them on mobile.
- Add `will-change` only 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 call `ctx.revert()` on cleanup.
- Kill or revert ScrollTriggers on page transitions before initializing the next route.

## Init Order

```js
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.

© MengTo · MIT · MengTo/Skills

Similar skills

Everything for Frontend and UI →

From the same set148

build-awwwards-quality-sites Provides art direction and implementation rules for motion-led, interactive websites. Guides the AI agent through GSAP choreography, smooth scrolling, and WebGL safeguards. CX Codex +1 3,100installs beautiful-shadows A skill that hands your agent three exact Tailwind arbitrary shadow classes for cards, panels and popovers. Useful when default shadow-sm and shadow-lg look blunt. CX Codex +1 2,200installs cinematic-scroll-storytelling Generates scroll-driven landing page animations using Lenis and GSAP ScrollTrigger. It helps structure editorial storytelling, sticky card stacks, and masked typography reveals. CX Codex +1 2,100installs threejs Guides your agent through building and debugging interactive Three.js scenes: scene, camera, lights, glTF loading, controls and performance. CX Codex +1 2,000installs landing-page Skill for planning and copywriting single-offer landing pages for web apps or services. Sets up section order, benefit-driven copy formulas, and objection handling. CX Codex +1 2,000installs animation-on-scroll A skill that wires up scroll-reveal with IntersectionObserver, plus Tailwind animation classes and keyframes. CX Codex +1 2,000installs animation-systems Motion design guide for clean, product-grade web animations like Stripe, Linear, and Apple. Useful when building UI transitions, hover states, and modals. CX Codex +1 1,900installs gsap A skill that helps your coding agent write and debug GSAP web animations: timelines, ScrollTrigger, stagger, transforms. CX Codex +1 1,800installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor