@mehshekari/gsap-motion 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +24 -0
  3. package/README.md +59 -0
  4. package/bin/gsap-motion.mjs +304 -0
  5. package/package.json +48 -0
  6. package/skill/LICENSE +21 -0
  7. package/skill/NOTICE.md +24 -0
  8. package/skill/SKILL.md +181 -0
  9. package/skill/example/cinematic-intro.md +202 -0
  10. package/skill/example/magnetic-button.md +101 -0
  11. package/skill/example/svg-loader.md +180 -0
  12. package/skill/preset/card-stack.md +175 -0
  13. package/skill/preset/cinematic.md +140 -0
  14. package/skill/preset/cursor.md +184 -0
  15. package/skill/preset/loader.md +172 -0
  16. package/skill/preset/magnetic.md +155 -0
  17. package/skill/preset/marquee.md +177 -0
  18. package/skill/preset/page-transition.md +151 -0
  19. package/skill/preset/reveal.md +146 -0
  20. package/skill/preset/scroll-storytelling.md +176 -0
  21. package/skill/reference/accessibility.md +143 -0
  22. package/skill/reference/core-gsap.md +233 -0
  23. package/skill/reference/flip.md +220 -0
  24. package/skill/reference/frameworks.md +153 -0
  25. package/skill/reference/interaction.md +183 -0
  26. package/skill/reference/motion-design.md +144 -0
  27. package/skill/reference/performance.md +216 -0
  28. package/skill/reference/project-rules.md +178 -0
  29. package/skill/reference/react-nextjs.md +234 -0
  30. package/skill/reference/routing.md +119 -0
  31. package/skill/reference/scrolltrigger.md +191 -0
  32. package/skill/reference/svg.md +201 -0
  33. package/skill/reference/text.md +150 -0
  34. package/skill/reference/three-r3f.md +161 -0
  35. package/skill/reference/timeline.md +147 -0
  36. package/skill/scripts/audit-gsap.mjs +157 -0
  37. package/skill/scripts/audit-svg.mjs +358 -0
  38. package/skill/scripts/lib/rules.mjs +555 -0
  39. package/skill/scripts/lib/source.mjs +238 -0
  40. package/skill/template/useScrollScene.ts +126 -0
  41. package/skill/template/useTimeline.ts +110 -0
@@ -0,0 +1,161 @@
1
+ # Three.js and React Three Fiber
2
+
3
+ GSAP is the **orchestration layer**, not the renderer. Three owns the scene,
4
+ the camera, and the draw loop. GSAP owns *when* values change and *how* they
5
+ ease between states.
6
+
7
+ Neither library comes with GSAP, and most projects have neither. Check
8
+ `package.json` before writing code against them, and say so if they are not
9
+ there.
10
+
11
+ ## The division
12
+
13
+ | GSAP | Three / R3F |
14
+ |---|---|
15
+ | timing, easing, sequencing | geometry, materials, lights |
16
+ | camera moves between positions | camera projection, the render loop |
17
+ | object transforms over time | the transforms themselves |
18
+ | material property transitions | shaders |
19
+ | tying it all to scroll | rendering the result |
20
+
21
+ Do not rebuild a physics engine or a particle system in GSAP. Do not hand-write
22
+ easing in `useFrame` when a tween would say it in one line.
23
+
24
+ ## The core pattern
25
+
26
+ Three objects are plain JS objects, so GSAP tweens them directly. The only thing
27
+ to remember is that nested properties are their own targets:
28
+
29
+ ```ts
30
+ gsap.to(mesh.position, { x: 2, y: 1, duration: 1.2, ease: "power3.inOut" });
31
+ gsap.to(mesh.rotation, { y: Math.PI * 2, duration: 3, ease: "none", repeat: -1 });
32
+ gsap.to(mesh.material, { opacity: 0, duration: 0.6 });
33
+ gsap.to(mesh.scale, { x: 1.4, y: 1.4, z: 1.4, duration: 0.5 });
34
+ ```
35
+
36
+ `gsap.to(mesh, { position: { x: 2 } })` does not work. Target `mesh.position`.
37
+
38
+ For a material property to animate opacity, `transparent` must already be true —
39
+ setting it mid-tween causes a shader recompile.
40
+
41
+ ## Uniforms
42
+
43
+ Shader uniforms are `{ value }` objects, which tween cleanly:
44
+
45
+ ```ts
46
+ gsap.to(material.uniforms.uProgress, { value: 1, duration: 1.5, ease: "power2.inOut" });
47
+ ```
48
+
49
+ This is the usual bridge between a GSAP timeline and a custom shader effect —
50
+ one uniform, driven by the timeline, interpreted by the shader.
51
+
52
+ ## R3F
53
+
54
+ The renderer runs its own loop, so nothing needs invalidating on demand unless
55
+ the canvas is set to `frameloop="demand"` — in which case call `invalidate()`
56
+ from `onUpdate`.
57
+
58
+ ```tsx
59
+ "use client";
60
+
61
+ import { useGSAP } from "@gsap/react";
62
+ import { useFrame, useThree } from "@react-three/fiber";
63
+ import gsap from "gsap";
64
+ import { useRef } from "react";
65
+ import type { Mesh } from "three";
66
+
67
+ export function Box() {
68
+ const mesh = useRef<Mesh>(null);
69
+
70
+ useGSAP(() => {
71
+ if (!mesh.current) return;
72
+ gsap.to(mesh.current.rotation, {
73
+ y: Math.PI * 2,
74
+ duration: 6,
75
+ ease: "none",
76
+ repeat: -1,
77
+ });
78
+ });
79
+
80
+ return (
81
+ <mesh ref={mesh}>
82
+ <boxGeometry />
83
+ <meshStandardMaterial />
84
+ </mesh>
85
+ );
86
+ }
87
+ ```
88
+
89
+ **Never drive a 3D transform through React state.** `useState` per frame
90
+ re-renders the React tree for a value Three reads directly. Refs and GSAP only.
91
+
92
+ `useFrame` and GSAP are complementary, not competing: `useFrame` for continuous
93
+ per-frame logic that depends on the clock, GSAP for transitions between states.
94
+
95
+ ## Camera
96
+
97
+ Camera moves are where GSAP earns its place — easing a camera by hand is how
98
+ scroll-driven 3D ends up feeling mechanical.
99
+
100
+ ```ts
101
+ const { camera } = useThree();
102
+
103
+ gsap.timeline({ defaults: { ease: "power2.inOut", duration: 2 } })
104
+ .to(camera.position, { x: 0, y: 1.5, z: 4 })
105
+ .to(camera.rotation, { x: -0.2 }, "<");
106
+ ```
107
+
108
+ If the camera uses `lookAt`, tween a proxy target and call `lookAt` in
109
+ `onUpdate` — otherwise the rotation you tween is overwritten every frame:
110
+
111
+ ```ts
112
+ const look = { x: 0, y: 0, z: 0 };
113
+ gsap.to(look, {
114
+ x: 2,
115
+ duration: 2,
116
+ onUpdate: () => camera.lookAt(look.x, look.y, look.z),
117
+ });
118
+ ```
119
+
120
+ ## Scroll-driven 3D
121
+
122
+ Scrub a timeline, do not compute from `scrollY` in `useFrame`:
123
+
124
+ ```ts
125
+ gsap.timeline({
126
+ scrollTrigger: { trigger: section, start: "top top", end: "+=2000",
127
+ pin: true, scrub: 1 },
128
+ })
129
+ .to(camera.position, { z: 3, ease: "none" })
130
+ .to(material.uniforms.uProgress, { value: 1, ease: "none" }, "<");
131
+ ```
132
+
133
+ `ease: "none"` on every child, as with any scrub. See
134
+ [scrolltrigger.md](scrolltrigger.md).
135
+
136
+ ## Cost and accessibility
137
+
138
+ 3D is the most expensive thing on a page, and the constraints are harder than
139
+ for DOM animation:
140
+
141
+ - A WebGL canvas runs the GPU continuously. On a phone that is battery and heat.
142
+ - Give the canvas a real fallback — a static image — for low-power devices and
143
+ for `prefers-reduced-motion`. Do not merely stop the animation and leave a
144
+ running canvas.
145
+ - Dynamically import the 3D component so Three is not in the initial bundle.
146
+ - `frameloop="demand"` when the scene is static between interactions.
147
+ - Dispose geometries, materials and textures on unmount; GSAP's cleanup does not
148
+ do that.
149
+ - Nothing essential — text, navigation, product information — should exist only
150
+ inside the canvas.
151
+
152
+ ## Cleanup
153
+
154
+ ```ts
155
+ useGSAP(() => {
156
+ const tl = gsap.timeline({ scrollTrigger: { ... } });
157
+ return () => { tl.scrollTrigger?.kill(); };
158
+ });
159
+ ```
160
+
161
+ Plus Three's own disposal, which is separate and yours to do.
@@ -0,0 +1,147 @@
1
+ # Timeline architecture
2
+
3
+ A timeline is how choreography stays editable. Five tweens with hand-tuned
4
+ delays encode the same animation in a form nobody can retime without redoing
5
+ the arithmetic.
6
+
7
+ ## Position
8
+
9
+ The third argument places a child on the timeline. This is the whole API.
10
+
11
+ ```ts
12
+ tl.to(a, { ... }) // after everything before it
13
+ .to(b, { ... }, "<") // with the previous tween's start
14
+ .to(c, { ... }, "<0.2") // 0.2s after the previous start
15
+ .to(d, { ... }, "-=0.3") // 0.3s before the timeline's current end
16
+ .to(e, { ... }, ">") // at the previous tween's end (explicit)
17
+ .to(f, { ... }, 1.5) // absolute time
18
+ .to(g, { ... }, "reveal") // at a label
19
+ .to(h, { ... }, "reveal+=0.4"); // relative to a label
20
+ ```
21
+
22
+ `<` is the one to reach for most. "Start with the previous thing, slightly
23
+ after" is what overlap sounds like in code, and it survives a duration change
24
+ upstream — `-=0.3` does not.
25
+
26
+ ## Labels
27
+
28
+ Labels turn a timeline into something you can read and something you can seek.
29
+
30
+ ```ts
31
+ const tl = gsap.timeline();
32
+
33
+ tl.addLabel("mark")
34
+ .fromTo("[data-outline]", { drawSVG: "0%" }, { drawSVG: "100%", duration: 1 })
35
+ .addLabel("word")
36
+ .to("[data-letter]", { autoAlpha: 1, stagger: 0.05 }, "word")
37
+ .to(dot, { motionPath: { ... } }, "word")
38
+ .addLabel("hold");
39
+ ```
40
+
41
+ Two children sharing a label start together and stay together when the beat
42
+ before them changes length. That is the point — a label is a commitment about
43
+ *structure*, where a number is a commitment about *time*.
44
+
45
+ `tl.seek("word")` and `tl.tweenTo("hold")` then work without counting.
46
+
47
+ ## Nesting
48
+
49
+ Build each beat as its own timeline and assemble them. This is what keeps a
50
+ long sequence readable, and it lets you reuse a beat.
51
+
52
+ ```ts
53
+ const enter = () =>
54
+ gsap.timeline().from("[data-line]", { yPercent: 110, stagger: 0.06 });
55
+
56
+ const master = gsap.timeline()
57
+ .add(enter())
58
+ .add(revealImage(), "-=0.4")
59
+ .add(settle());
60
+ ```
61
+
62
+ A nested timeline keeps its own internal timing when the parent's `timeScale`
63
+ changes, which is how you slow a whole sequence without touching each beat.
64
+
65
+ ## Defaults
66
+
67
+ Repeating `duration` and `ease` on every child is noise and drifts:
68
+
69
+ ```ts
70
+ const tl = gsap.timeline({
71
+ defaults: { duration: 0.6, ease: "power3.out" },
72
+ });
73
+ ```
74
+
75
+ Children override individually. Set the house values once here.
76
+
77
+ ## Where `repeat` belongs
78
+
79
+ On the child, not the parent, when only part of the sequence loops:
80
+
81
+ ```ts
82
+ tl.from("[data-mark]", { ... }) // plays once
83
+ .to(dot, { rotation: 360, repeat: -1, ease: "none" }); // loops forever
84
+ ```
85
+
86
+ `repeat: -1` on the timeline replays the entrance too. And a timeline containing
87
+ an infinitely repeating child **never completes**, so its `onComplete` never
88
+ fires — if something downstream waits on that, give the timeline a finite beat
89
+ of its own to end on, or drive the handover from elsewhere.
90
+
91
+ ## Paused timelines as state
92
+
93
+ Build once, play on demand. Far better than creating a tween per event.
94
+
95
+ ```ts
96
+ const hover = gsap.timeline({ paused: true })
97
+ .to(el, { scale: 1.05, duration: 0.3 });
98
+
99
+ onMouseEnter = () => hover.play();
100
+ onMouseLeave = () => hover.reverse();
101
+ ```
102
+
103
+ `reverse()` uses the same easing backwards, so the return matches the arrival
104
+ without a second definition. In React, anything built outside the `useGSAP`
105
+ body must go through `contextSafe` — see [react-nextjs.md](react-nextjs.md).
106
+
107
+ ## Holds
108
+
109
+ An empty tween is a pause that is visible in the structure:
110
+
111
+ ```ts
112
+ tl.to(shape, { morphSVG: "#b" })
113
+ .to({}, { duration: 0.4 }) // hold, legible in the timeline
114
+ .to(shape, { morphSVG: "#c" });
115
+ ```
116
+
117
+ Clearer than `delay` on the next tween, because the hold is a beat rather than a
118
+ property of something else.
119
+
120
+ ## Scrubbing
121
+
122
+ A paused timeline driven by an external value is the pattern behind every
123
+ progress-bound animation:
124
+
125
+ ```ts
126
+ const tl = gsap.timeline({ paused: true });
127
+ tl.fromTo(ring, { drawSVG: "0%" }, { drawSVG: "100%", ease: "none" });
128
+
129
+ gsap.to(tl, { progress, duration: 0.4, ease: "power2.out", overwrite: true });
130
+ ```
131
+
132
+ Tween the progress rather than setting it — that is what smooths a value
133
+ arriving as 0 → 40 → 100. `overwrite: true` stops two updates in flight from
134
+ fighting. `ease: "none"` inside, because the outer tween owns the feel.
135
+
136
+ ScrollTrigger's `scrub` is this same mechanism with scroll as the value.
137
+
138
+ ## Common failures
139
+
140
+ | Symptom | Cause |
141
+ |---|---|
142
+ | Reads like a slideshow | no overlap — nothing uses `<` or a negative offset |
143
+ | Retiming one beat breaks the rest | `-=` offsets instead of labels |
144
+ | `onComplete` never fires | an infinitely repeating child |
145
+ | Entrance replays on loop | `repeat: -1` on the timeline, not the child |
146
+ | Flash of the end state before it runs | `from` without a `set`, outside a layout effect |
147
+ | Two beats drift apart after an edit | both timed by number instead of sharing a label |
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Audits GSAP animation code for the failures that are silent.
5
+ *
6
+ * Nothing here is a matter of taste. Every rule catches something that
7
+ * produces no error and often no visible symptom until much later, on a slower
8
+ * device, or after a navigation: a tween that outlives its component, a tween
9
+ * allocated per pointer event, a layout property animated every frame, an ease
10
+ * on a loop that makes its own seam visible, a hardcoded id that makes two
11
+ * instances drive one another's geometry.
12
+ *
13
+ * Rhythm, easing choice and whether an animation earns its place are not
14
+ * checkable and are not attempted — reference/motion-design.md owns those.
15
+ *
16
+ * This is static analysis without a parser, so it is deliberately conservative
17
+ * and will still be wrong sometimes. `info` findings have the loosest match.
18
+ * A wrong finding is a bug in this script; report it rather than working
19
+ * around it.
20
+ *
21
+ * Usage — paths resolve from the current directory, so run it from the project
22
+ * root, wherever the skill itself is installed:
23
+ * node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
24
+ * node <skill-dir>/scripts/audit-gsap.mjs src/components --json
25
+ * node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
26
+ *
27
+ * Exit code is 1 when any error-level finding is reported, 0 otherwise.
28
+ */
29
+ import { existsSync } from "node:fs";
30
+ import { resolve } from "node:path";
31
+
32
+ import { RULES } from "./lib/rules.mjs";
33
+ import { collect, lineAt, load } from "./lib/source.mjs";
34
+
35
+ /**
36
+ * The project being audited is wherever this is run from, not wherever the
37
+ * skill is installed. It used to be derived from this file's own location,
38
+ * which only worked while the skill sat exactly four levels inside the project:
39
+ * installed per user under `~/.claude/skills/`, it resolved `src` against the
40
+ * home directory and every run exited with "No such path".
41
+ */
42
+ const ROOT = process.cwd();
43
+
44
+ const argv = process.argv.slice(2);
45
+ const asJson = argv.includes("--json");
46
+ const quiet = argv.includes("--quiet");
47
+ const targets = argv.filter((a) => !a.startsWith("--"));
48
+
49
+ const paths = (targets.length ? targets : ["src"]).map((p) => resolve(ROOT, p));
50
+
51
+ for (const path of paths) {
52
+ if (!existsSync(path)) {
53
+ console.error(`No such path: ${path}`);
54
+ process.exit(2);
55
+ }
56
+ }
57
+
58
+ // --- Escape hatch ------------------------------------------------------------
59
+
60
+ /**
61
+ * The same shape many repos already use for their own lint waivers, on
62
+ * purpose: one way to say "yes, and here is why", not two.
63
+ *
64
+ * A hatch is the rule's own id plus `-ok`, on the flagged line or in the eight
65
+ * above it, and it waives that rule and nothing else — silencing a shared id
66
+ * cannot also silence a layout property on the same line. The lookback is eight
67
+ * because a reason usually stands above a whole declaration rather than beside
68
+ * one line of it.
69
+ *
70
+ * Every hatch must carry its reason. A bare token with no sentence after it is
71
+ * a rule someone turned off rather than answered, and once this gates a build
72
+ * that distinction has to stay visible in review.
73
+ */
74
+ const LOOKBACK = 8;
75
+
76
+ function waived(source, line, ruleId) {
77
+ const lines = source.split("\n");
78
+ const from = Math.max(0, line - 1 - LOOKBACK);
79
+ return lines.slice(from, line).join("\n").includes(`${ruleId}-ok`);
80
+ }
81
+
82
+ // --- Run ---------------------------------------------------------------------
83
+
84
+ const findings = [];
85
+
86
+ for (const path of paths) {
87
+ for (const file of collect(path)) {
88
+ const source = load(file, ROOT);
89
+
90
+ /**
91
+ * A file that never mentions GSAP has nothing these rules can say. Checked
92
+ * once here rather than in every rule.
93
+ */
94
+ if (!source.usesGsap && !/\bgsap\./.test(source.code)) continue;
95
+
96
+ for (const rule of RULES) {
97
+ for (const hit of rule.test(source)) {
98
+ const line = lineAt(source.raw, hit.index);
99
+ if (waived(source.raw, line, rule.id)) continue;
100
+
101
+ findings.push({
102
+ rule: rule.id,
103
+ level: rule.level,
104
+ file: source.display,
105
+ line,
106
+ message: hit.message,
107
+ hint: hit.hint,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ // --- Report ------------------------------------------------------------------
115
+
116
+ const ORDER = { error: 0, warn: 1, info: 2 };
117
+ const ICON = { error: "✗", warn: "!", info: "i" };
118
+
119
+ findings.sort(
120
+ (a, b) =>
121
+ ORDER[a.level] - ORDER[b.level] ||
122
+ a.file.localeCompare(b.file) ||
123
+ a.line - b.line,
124
+ );
125
+
126
+ const shown = quiet ? findings.filter((f) => f.level === "error") : findings;
127
+
128
+ if (asJson) {
129
+ console.log(JSON.stringify({ findings: shown }, null, 2));
130
+ } else if (!shown.length) {
131
+ console.log(
132
+ findings.length
133
+ ? `gsap-audit: no errors (${findings.length} lower-severity finding(s) hidden by --quiet)`
134
+ : "gsap-audit: clean",
135
+ );
136
+ } else {
137
+ let current = "";
138
+ for (const f of shown) {
139
+ if (f.file !== current) {
140
+ current = f.file;
141
+ console.log(`\n${current}`);
142
+ }
143
+ console.log(` ${ICON[f.level]} ${f.line}: ${f.message} [${f.rule}]`);
144
+ console.log(` ${f.hint}`);
145
+ }
146
+
147
+ const counts = ORDER;
148
+ const tally = Object.keys(counts)
149
+ .map((level) => [level, findings.filter((f) => f.level === level).length])
150
+ .filter(([, n]) => n > 0)
151
+ .map(([level, n]) => `${n} ${level}`)
152
+ .join(", ");
153
+
154
+ console.log(`\n${tally}`);
155
+ }
156
+
157
+ process.exit(findings.some((f) => f.level === "error") ? 1 : 0);