@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,119 @@
1
+ # Routing
2
+
3
+ What to load, and what to do when the request is a feeling rather than a
4
+ command.
5
+
6
+ ## No argument
7
+
8
+ Do not guess a target and do not invent an animation. Ask what should move, and
9
+ offer the menu below — grouped, so the question is easy to answer.
10
+
11
+ > What are we animating?
12
+ >
13
+ > - **Entrance** — something appearing: a hero, a section, a list, an image
14
+ > - **Scroll** — something driven by scroll position: a sequence, a pin, a
15
+ > horizontal run
16
+ > - **Interaction** — something answering the pointer: a button, a cursor, a
17
+ > card
18
+ > - **Continuous** — something always moving: a marquee, an orbit, ambient drift
19
+ > - **Transition** — moving between two states: a route, a modal, a layout
20
+ > - **Loading** — a loader, a progress indicator, a site intro
21
+ > - **Existing** — something already animated that feels wrong, costs too much,
22
+ > or should go
23
+
24
+ ## Classifying a description
25
+
26
+ Most requests are a feeling, not a command. Classify, then route.
27
+
28
+ | What they say | What they mean | Route to |
29
+ |---|---|---|
30
+ | "make it cinematic" | slow, weighted, sequenced, one thing at a time | `intro` · [cinematic](../preset/cinematic.md) |
31
+ | "make it feel premium" | fewer moving parts, longer eases, no bounce | `tune` · [motion-design](motion-design.md) |
32
+ | "make it feel alive" | ambient continuous motion under static content | `ambient` · [marquee](../preset/marquee.md) |
33
+ | "make it pop / bolder" | more contrast in timing, not more elements moving | `tune` · [motion-design](motion-design.md) |
34
+ | "like Apple" | scroll-scrubbed, pinned, one idea per screen | `scroll` · [scroll-storytelling](../preset/scroll-storytelling.md) |
35
+ | "futuristic / technical" | scramble, draw-on, mechanical easing, grid reveals | `text` + `svg` |
36
+ | "liquid / organic" | morph, path motion, overlapping soft easing | `svg` · [loader](../preset/loader.md) |
37
+ | "something crazy" | layered techniques with one clear concept | see below |
38
+ | "smooth image reveal" | clip-path or mask, not opacity | [reveal](../preset/reveal.md) |
39
+ | "the hero is boring" | hierarchy problem before a motion problem | `tune`, then `reveal` |
40
+ | "it feels janky" | cost or trigger problem | `audit` · [performance](performance.md) |
41
+
42
+ ### "Something crazy"
43
+
44
+ A fade-and-scale is a failure of nerve; twelve techniques at once is a failure
45
+ of taste. Pick **one concept** and let three techniques serve it. The concept is
46
+ the thing you can say in a sentence — "the logo writes itself, then the dot that
47
+ drew it runs off and writes the word" — and every technique either serves that
48
+ sentence or is cut.
49
+
50
+ ## Load map
51
+
52
+ The project's own rules come first, then the size tiers in SKILL.md, setup step
53
+ 3: a one-element change loads [motion-design](motion-design.md) and the
54
+ command's own reference only. The table applies from component size up — a
55
+ single hover takes [interaction](interaction.md) and nothing else from its row.
56
+
57
+ | Request | Add |
58
+ |---|---|
59
+ | SVG draw / morph / path | [svg](svg.md), [timeline](timeline.md) |
60
+ | Scroll sequence, pin, scrub | [scrolltrigger](scrolltrigger.md), [timeline](timeline.md), [performance](performance.md) |
61
+ | Horizontal scroll | [scrolltrigger](scrolltrigger.md), [scroll-storytelling](../preset/scroll-storytelling.md) |
62
+ | Text reveal, kinetic type | [text](text.md), [timeline](timeline.md) |
63
+ | Magnetic, cursor, hover | [interaction](interaction.md), [performance](performance.md) |
64
+ | Drag, flick, inertia | [interaction](interaction.md) |
65
+ | Layout / shared element / modal | [flip](flip.md) |
66
+ | Page or route transition | [flip](flip.md), [page-transition](../preset/page-transition.md), [react-nextjs](react-nextjs.md) or [frameworks](frameworks.md) |
67
+ | Loader, splash, intro | [svg](svg.md), [timeline](timeline.md), [accessibility](accessibility.md) |
68
+ | Marquee, orbit, ambient | [timeline](timeline.md), [performance](performance.md) |
69
+ | Three.js / R3F | [three-r3f](three-r3f.md), [performance](performance.md) |
70
+ | Audit an existing animation | [performance](performance.md), [accessibility](accessibility.md), [react-nextjs](react-nextjs.md) or [frameworks](frameworks.md) |
71
+ | Retime / re-ease | [motion-design](motion-design.md) only |
72
+
73
+ Do not load a domain file "for completeness". Loading `three-r3f.md` for a
74
+ button hover is the failure this map exists to prevent.
75
+
76
+ ## `audit`
77
+
78
+ **Run the checker first.** It is deterministic, it costs a second, and it finds
79
+ the mechanical failures before you spend attention on them:
80
+
81
+ ```bash
82
+ node .claude/skills/gsap-creative-animation/scripts/audit-gsap.mjs <path>
83
+ ```
84
+
85
+ **When an animation is not visible, ask whether it was ever built.** Before
86
+ touching timings or transforms, check that the code which creates it actually
87
+ runs. Animation is routinely built behind a condition — an image decode race, a
88
+ `ready.then()`, a ScrollTrigger that has not fired, a `matchMedia` branch that
89
+ does not match, an early `return` on a missing ref — and a tween that was never
90
+ created looks exactly like one that is mis-timed. A typical case: an image wipe
91
+ added only once the image decodes inside a short budget. On a first visit the
92
+ image is never cached, the branch never runs, and every hypothesis about the
93
+ wipe's geometry is wasted effort.
94
+
95
+ Then read for what it cannot see. Check in this order — the first two are where
96
+ real bugs live:
97
+
98
+ 1. **Lifecycle** — is every animation inside a `useGSAP`/`gsap.context` scope?
99
+ Are ScrollTriggers killed? Are listeners removed? Does a `matchMedia` get
100
+ reverted? See [react-nextjs](react-nextjs.md).
101
+ 2. **Cost** — layout-triggering properties, per-frame React state, filters on
102
+ large areas, one ScrollTrigger per item where one would do. See
103
+ [performance](performance.md).
104
+ 3. **Reduced motion** — is there a branch, and does it still communicate?
105
+ 4. **Craft** — rhythm, easing on loops, contrast. See [motion-design](motion-design.md).
106
+
107
+ Report findings most-severe first, with the file and line. Do not rewrite
108
+ unless asked.
109
+
110
+ ## `strip`
111
+
112
+ Removing motion is a legitimate outcome and often the right one. Cut an
113
+ animation when it answers none of hierarchy, causality, continuity, state, or
114
+ personality; when it delays content the visitor came for; when it repeats on
115
+ every route and stopped being noticed on the second one; or when it exists
116
+ because a plugin was available.
117
+
118
+ Say what you removed and what it was costing. Do not replace it with a smaller
119
+ animation unless the user asks.
@@ -0,0 +1,191 @@
1
+ # ScrollTrigger
2
+
3
+ Scroll position as a trigger or as a playhead. The difference between those two
4
+ is the first decision.
5
+
6
+ ```ts
7
+ import { ScrollTrigger } from "gsap/ScrollTrigger";
8
+ gsap.registerPlugin(ScrollTrigger);
9
+ ```
10
+
11
+ ## Trigger or playhead
12
+
13
+ | Want | Use | Ease |
14
+ |---|---|---|
15
+ | Play once when it comes into view | no `scrub` | normal easing |
16
+ | Motion tied to scroll position | `scrub: true` | **`ease: "none"`** |
17
+ | Motion tied to scroll, slightly lagged | `scrub: 1` | `none` |
18
+
19
+ `scrub` makes the scrollbar the playhead. Easing inside a scrubbed timeline
20
+ fights the visitor's own scrolling and reads as lag — use `none` on every child
21
+ and let `scrub: 1` provide the smoothing.
22
+
23
+ ## The config that matters
24
+
25
+ ```ts
26
+ ScrollTrigger.create({
27
+ trigger: section,
28
+ start: "top 80%", // trigger's top hits 80% down the viewport
29
+ end: "bottom 20%",
30
+ scrub: 1, // true = locked, number = seconds of catch-up
31
+ pin: true,
32
+ anticipatePin: 1, // reduces the flicker when pinning at speed
33
+ snap: { snapTo: 1 / 4, duration: 0.4, ease: "power2.inOut" },
34
+ markers: process.env.NODE_ENV === "development",
35
+ invalidateOnRefresh: true,
36
+ toggleActions: "play none none reverse",
37
+ });
38
+ ```
39
+
40
+ `start` / `end` read as `"<trigger edge> <viewport position>"`. Both accept
41
+ `"top"`, `"center"`, `"bottom"`, a percentage, or a px offset — and `end` also
42
+ accepts `"+=800"`, meaning "800px of scrolling after the start", which is what
43
+ you want for a pinned sequence.
44
+
45
+ `toggleActions` is four states in order: **onEnter, onLeave, onEnterBack,
46
+ onLeaveBack**. `"play none none reverse"` is the usual reveal. `"play none none
47
+ none"` plays once and never again.
48
+
49
+ ## One trigger, many elements
50
+
51
+ The most common mistake is one ScrollTrigger per card. If a group belongs to one
52
+ visual moment, it is one trigger with a staggered timeline:
53
+
54
+ ```ts
55
+ // Wrong — 40 triggers, 40 sets of scroll maths
56
+ cards.forEach((card) =>
57
+ gsap.from(card, { y: 40, scrollTrigger: { trigger: card } }),
58
+ );
59
+
60
+ // Right — one trigger, one timeline
61
+ gsap.from("[data-card]", {
62
+ y: 40,
63
+ autoAlpha: 0,
64
+ stagger: 0.06,
65
+ scrollTrigger: { trigger: grid, start: "top 75%" },
66
+ });
67
+ ```
68
+
69
+ Per-item triggers are correct when items are genuinely far apart and reveal
70
+ independently. For a grid on one screen they are not.
71
+
72
+ ## Pinning
73
+
74
+ `pin` holds the element while the page scrolls past its `end`. The scroll
75
+ distance is what you are really authoring:
76
+
77
+ ```ts
78
+ gsap.timeline({
79
+ scrollTrigger: {
80
+ trigger: section,
81
+ start: "top top",
82
+ end: "+=2000", // 2000px of scroll drives the sequence
83
+ pin: true,
84
+ scrub: 1,
85
+ },
86
+ })
87
+ .to(a, { autoAlpha: 1 })
88
+ .to(b, { autoAlpha: 1 });
89
+ ```
90
+
91
+ Things that bite:
92
+
93
+ - **A pinned element cannot be inside a container with `overflow: hidden`** on
94
+ the scroll axis. This is the usual cause of "pin does nothing".
95
+ - Pin adds a wrapper and a spacer to the DOM. Sibling selectors and
96
+ `:first-child` break.
97
+ - `position: sticky` is cheaper and needs no plugin. Use it when you only need
98
+ the element to stay put, not to drive a timeline.
99
+
100
+ ## Horizontal scroll
101
+
102
+ A vertical scroll driving a horizontal translation. `xPercent` avoids measuring:
103
+
104
+ ```ts
105
+ const panels = gsap.utils.toArray<HTMLElement>("[data-panel]");
106
+
107
+ gsap.to(panels, {
108
+ xPercent: -100 * (panels.length - 1),
109
+ ease: "none",
110
+ scrollTrigger: {
111
+ trigger: track,
112
+ pin: true,
113
+ scrub: 1,
114
+ end: () => `+=${track.offsetWidth}`,
115
+ snap: 1 / (panels.length - 1),
116
+ invalidateOnRefresh: true,
117
+ },
118
+ });
119
+ ```
120
+
121
+ `end` as a function is re-evaluated on refresh, which is what keeps it right
122
+ after a resize. In RTL the direction inverts — see
123
+ [project-rules.md](project-rules.md).
124
+
125
+ Horizontal scroll costs the visitor their scroll intuition. Use it when the
126
+ content is genuinely a sequence, not to be interesting.
127
+
128
+ ## Refresh
129
+
130
+ ScrollTrigger measures once and caches. Anything that changes layout after that
131
+ invalidates the measurement.
132
+
133
+ ```ts
134
+ ScrollTrigger.refresh(); // after fonts, images, accordions, route changes
135
+ ```
136
+
137
+ - `invalidateOnRefresh: true` recomputes tween start values too, which is what
138
+ you want when a `from` depends on measured size.
139
+ - Images without dimensions are the usual cause of triggers drifting: the page
140
+ grows after measurement. Give them width/height or an aspect ratio.
141
+ - Web fonts shift text height on load. Refresh after `document.fonts.ready`.
142
+
143
+ ## Velocity
144
+
145
+ Scroll speed as an input — a cheap way to make a section feel responsive:
146
+
147
+ ```ts
148
+ const skew = gsap.quickTo("[data-card]", "skewY", { duration: 0.6, ease: "power3" });
149
+
150
+ ScrollTrigger.create({
151
+ onUpdate: (self) => skew(gsap.utils.clamp(-12, 12, self.getVelocity() / -250)),
152
+ });
153
+ ```
154
+
155
+ `getVelocity()` is px/sec and signed. Clamp it — unclamped it produces absurd
156
+ values on a flick.
157
+
158
+ ## ScrollSmoother
159
+
160
+ Free, and a large commitment: it takes over the scroll container, which
161
+ interferes with `position: fixed`, anchor links, and native scroll on some
162
+ touch devices. Do not add it to make a reveal smoother — fix the reveal. Add it
163
+ only when the whole page is designed around smoothed scrolling.
164
+
165
+ ## React
166
+
167
+ The lifecycle is the hard part. `useGSAP` reverts its context on unmount, which
168
+ kills ScrollTriggers created inside it. Anything created outside must be killed
169
+ by hand:
170
+
171
+ ```ts
172
+ useGSAP(() => {
173
+ gsap.to(el, { scrollTrigger: { ... } }); // reverted automatically
174
+ }, { scope: root });
175
+ ```
176
+
177
+ After a route change in the App Router, the new page's layout is measured before
178
+ images settle. `ScrollTrigger.refresh()` on mount is usually needed. See
179
+ [react-nextjs.md](react-nextjs.md).
180
+
181
+ ## Failures
182
+
183
+ | Symptom | Cause |
184
+ |---|---|
185
+ | Nothing happens | `trigger` element not in the DOM when created |
186
+ | Pin does nothing | an ancestor has `overflow: hidden` |
187
+ | Triggers fire at wrong positions | layout changed after measurement — refresh |
188
+ | Scrubbed motion feels laggy | easing inside a scrubbed timeline |
189
+ | Janky while scrolling | layout-triggering properties — see [performance](performance.md) |
190
+ | Works, then breaks after navigation | ScrollTriggers not killed on unmount |
191
+ | Wrong on mobile after address-bar hide | viewport resize — `invalidateOnRefresh` |
@@ -0,0 +1,201 @@
1
+ # SVG
2
+
3
+ SVG is one domain among several — reach for it when the shape itself is the
4
+ idea, not because the asset happens to be an SVG. A logo fading in is a
5
+ transform job.
6
+
7
+ **Run the audit first.** Every failure below is silent at runtime: DrawSVG on a
8
+ fill-only path animates nothing and throws nothing.
9
+
10
+ ```bash
11
+ node .claude/skills/gsap-creative-animation/scripts/audit-svg.mjs <file.svg>
12
+ node .claude/skills/gsap-creative-animation/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
13
+ ```
14
+
15
+ ## Preparation
16
+
17
+ ### It must be inline in the DOM
18
+
19
+ GSAP animates DOM nodes. `<img src="x.svg">`, `next/image`, and a CSS
20
+ `background` all put the shapes somewhere GSAP cannot reach. Inline the markup
21
+ as JSX.
22
+
23
+ ### Ids on everything the timeline targets
24
+
25
+ Without them you end up at `querySelectorAll("path")[3]`, which breaks on the
26
+ next export. Prefix them, and namespace per instance — see
27
+ [react-nextjs.md](react-nextjs.md). For a set animated together, one
28
+ `data-*` attribute beats a run of ids:
29
+
30
+ ```tsx
31
+ <path data-petal d="…" />
32
+ ```
33
+
34
+ ### `viewBox` in, `width`/`height` out
35
+
36
+ MotionPath aligns in user units and converts coordinate systems through the
37
+ `viewBox`. Without one there is nothing to convert from. Size with CSS — that is
38
+ also what lets one loader work at 32px and 200px.
39
+
40
+ **The box must contain everything the animation draws, not just the ink.** An
41
+ orbit that circles a wide lockup is taller than the artwork, and sized to the
42
+ artwork alone the element is clipped for part of every lap.
43
+
44
+ ### Colours
45
+
46
+ A theme cannot reach a hard-coded hue, and many projects lint for them — SVG
47
+ attributes are string literals like any other. Prefer `currentColor`, then one
48
+ of the project's CSS variables. `audit-svg.mjs --hues` lists every hue literal
49
+ in a file before it is pasted. See [project-rules.md](project-rules.md).
50
+
51
+ Export tools put paint in a `<style>` block keyed by generated class names
52
+ (`.st0`, `.cls-1`). Two traps: an attribute-only scan of the JSX will not see
53
+ those hues, and **the class names are not stable between files** — the same
54
+ `st0` can be the brand yellow in one export and a grey in another. Read the
55
+ declared fill, never the class name.
56
+
57
+ ### Strip the export noise
58
+
59
+ `<title>`/`<desc>` (decide accessibility deliberately instead), whole-drawing
60
+ `clip-path` wrappers, `pathLength` (it rescales the dash maths DrawSVG relies
61
+ on), `style` blocks, `xlink:href`. In JSX: `class`→`className`,
62
+ `stroke-width`→`strokeWidth`, `stop-color`→`stopColor`, `fill-rule`→`fillRule`.
63
+
64
+ ## DrawSVGPlugin
65
+
66
+ Animates a **stroke** by driving `stroke-dasharray` and `stroke-dashoffset`.
67
+
68
+ ```ts
69
+ gsap.fromTo("#outline", { drawSVG: "0%" }, { drawSVG: "100%", duration: 1.4 });
70
+ ```
71
+
72
+ | Value | Means |
73
+ |---|---|
74
+ | `"0%"` | zero-length at the start — invisible |
75
+ | `"100%"` | the whole stroke |
76
+ | `"50% 100%"` | only the second half visible |
77
+ | `"20% 20%"` | zero-length at 20% along |
78
+
79
+ A travelling dash — the comet loader — is a tween between two same-width
80
+ windows:
81
+
82
+ ```ts
83
+ gsap.fromTo("#ring", { drawSVG: "0% 25%" },
84
+ { drawSVG: "75% 100%", repeat: -1, ease: "none", duration: 1.2 });
85
+ ```
86
+
87
+ **It does nothing on a shape with no stroke, and throws nothing.** A filled
88
+ logo that must draw itself needs two layers of the same geometry: a stroked copy
89
+ that draws, then the filled version fading in under it, then the outline fading
90
+ out. A useful side effect — with JavaScript unavailable the outline is what
91
+ renders, so the fallback is the mark in outline rather than an empty box.
92
+
93
+ Set `strokeLinecap="round"` unless the design says otherwise; a half-drawn butt
94
+ cap ends in a visible flat edge.
95
+
96
+ ## MorphSVGPlugin
97
+
98
+ Tweens one path's `d` into another's.
99
+
100
+ ```ts
101
+ gsap.to("#shape", {
102
+ morphSVG: { shape: "#target", shapeIndex: 3, type: "rotational" },
103
+ });
104
+ ```
105
+
106
+ - `shapeIndex` decides which node of the target lines up with node 0 of the
107
+ source. It is the difference between a glide and a spin, and the default is a
108
+ guess. Find it once with `MorphSVGPlugin.findShapeIndex("#a", "#b")` in the
109
+ browser console, then hardcode the number.
110
+ - `type: "rotational"` keeps segment angles instead of straight-lining points.
111
+ Much better for organic shapes.
112
+ - `map` decides how the two shapes' **subpaths** are paired when each has
113
+ several — a letter with a counter, a logo of separate marks. `"size"` is the
114
+ default and pairs by area; `"position"` pairs by where they sit; and
115
+ `"complexity"` pairs by node count, which is the one to reach for when the
116
+ subpaths are similar in size but not in detail.
117
+
118
+ Both sides must be `<path>`:
119
+
120
+ ```ts
121
+ MorphSVGPlugin.convertToPath("circle, rect, ellipse, line, polygon, polyline");
122
+ ```
123
+
124
+ Convert in the source file where you can — a runtime conversion mutates the DOM
125
+ React thinks it owns. It is safe for static markup React never re-renders.
126
+
127
+ **Keep node counts within about 3×.** Beyond that MorphSVG subdivides the
128
+ simpler path, which is mathematically right and reads as a smear; the fix is to
129
+ redraw a shape, not to tune the tween. Best of all, author both shapes with the
130
+ **same command sequence** — a dot as a stadium with its straight run closed to
131
+ nothing morphs into a dash perfectly.
132
+
133
+ Target shapes should be unpainted (`fill="none" stroke="none"`) but **still
134
+ rendered** — `display: none` leaves no geometry to read.
135
+
136
+ ## MotionPathPlugin
137
+
138
+ Moves an element along a path.
139
+
140
+ ```ts
141
+ gsap.to("#dot", {
142
+ motionPath: {
143
+ path: "#track",
144
+ align: "#track",
145
+ alignOrigin: [0.5, 0.5],
146
+ autoRotate: true,
147
+ start: 0,
148
+ end: 1,
149
+ },
150
+ duration: 3,
151
+ repeat: -1,
152
+ ease: "none",
153
+ });
154
+ ```
155
+
156
+ - **`align` is the one people forget.** Without it the path's coordinates are
157
+ offsets from the element's current position — the cause of "it moves, but
158
+ nowhere near the path".
159
+ - `alignOrigin: [0.5, 0.5]` centres the element; the default is its top-left.
160
+ - `autoRotate: true` points it along travel. A number adds degrees, for art
161
+ whose "forward" is not to the right.
162
+ - **`start: 1, end: 0` runs the path backwards** — which is how you mirror for
163
+ RTL without authoring a second path.
164
+
165
+ A route is geometry, not decoration: `fill="none" stroke="none"`, still
166
+ rendered. `ease: "none"` on anything looping.
167
+
168
+ To author a path by eye, use `MotionPathHelper` in development only and bake the
169
+ `d` it gives you. It is an editor — never ship it.
170
+
171
+ ## Masks and clip paths
172
+
173
+ Often better than either plugin, and cheaper. A mask reveal is a transform on a
174
+ rect inside a `<mask>`:
175
+
176
+ ```tsx
177
+ <mask id={`${uid}-wipe`}>
178
+ <rect data-wipe x="0" y="0" width="100%" height="100%" fill="#fff" />
179
+ </mask>
180
+ <g mask={`url(#${uid}-wipe)`}>…</g>
181
+ ```
182
+
183
+ ```ts
184
+ gsap.from("[data-wipe]", { scaleX: 0, transformOrigin: "0% 50%", duration: 0.8 });
185
+ ```
186
+
187
+ Greyscale in a mask is an alpha stop, not a colour — the one literal this
188
+ repo's colour rule allows.
189
+
190
+ ## Choosing
191
+
192
+ ```text
193
+ stroke, and the drawing is the idea → DrawSVG
194
+ two shapes, and the change is the idea → MorphSVG
195
+ something travelling a route → MotionPath
196
+ a reveal → mask or clip-path
197
+ moving, scaling, rotating whole groups → plain transforms
198
+ ```
199
+
200
+ Never force MorphSVG or DrawSVG onto a structure that does not support it. Say
201
+ why, and offer the alternative.
@@ -0,0 +1,150 @@
1
+ # Text animation
2
+
3
+ Text is the content the visitor came for. Animating it delays reading, so the
4
+ bar is higher than for decoration: it must be fast, it must not break selection
5
+ or screen readers, and it must not reflow.
6
+
7
+ ## SplitText
8
+
9
+ Free and installed. Splits into chars, words, or lines and gives you the arrays.
10
+
11
+ ```ts
12
+ import { SplitText } from "gsap/SplitText";
13
+ gsap.registerPlugin(SplitText);
14
+
15
+ const split = new SplitText(heading, {
16
+ type: "lines,words",
17
+ linesClass: "rs-line",
18
+ mask: "lines", // wraps each line in an overflow-hidden parent
19
+ autoSplit: true, // re-splits on resize and font load
20
+ });
21
+
22
+ gsap.from(split.lines, {
23
+ yPercent: 110,
24
+ duration: 0.8,
25
+ stagger: 0.08,
26
+ ease: "power3.out",
27
+ });
28
+
29
+ // Cleanup — puts the original markup back:
30
+ split.revert();
31
+ ```
32
+
33
+ `mask: "lines"` is what makes a line rise out of nothing instead of sliding in
34
+ from below. It is the single option that separates a polished reveal from a
35
+ generic one.
36
+
37
+ `autoSplit: true` handles the two things that silently break a split: a web font
38
+ loading after the split and changing line breaks, and a resize reflowing them.
39
+ Without it, lines end up wrapped mid-phrase.
40
+
41
+ ### Which unit
42
+
43
+ - **Lines** — the default for headings and paragraphs. Reads as writing.
44
+ - **Words** — for short, deliberate phrases. Six words maximum.
45
+ - **Chars** — for a logo, a number, a single word. Never a sentence: a paragraph
46
+ revealing per character is unreadable and produces hundreds of nodes.
47
+
48
+ ### Accessibility
49
+
50
+ Splitting replaces the text with a pile of `<div>`s. SplitText sets
51
+ `aria-label` on the container from the original text and hides the fragments —
52
+ verify it, because a heading read as forty letters is a serious regression.
53
+
54
+ Always `revert()` on unmount. Leaving a split in place breaks copy-paste and
55
+ leaves the DOM heavier than it needs to be.
56
+
57
+ ### Cost
58
+
59
+ Splitting a paragraph into characters can produce a thousand elements. Split
60
+ lines, animate lines. If you must animate characters, cap it at a headline.
61
+
62
+ ## ScrambleTextPlugin
63
+
64
+ Cycles characters before settling on the target — the "decoding" effect.
65
+
66
+ ```ts
67
+ import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
68
+
69
+ gsap.to(el, {
70
+ duration: 1.2,
71
+ scrambleText: {
72
+ text: "LAUNCHING",
73
+ chars: "upperCase", // or "lowerCase", "upperAndLowerCase", or your own
74
+ speed: 0.4,
75
+ revealDelay: 0.3,
76
+ tweenLength: false,
77
+ },
78
+ });
79
+ ```
80
+
81
+ `tweenLength: false` keeps the element's width fixed, which stops the layout
82
+ jumping as the string grows. Almost always what you want.
83
+
84
+ Reads as technical and futuristic. It also makes text unreadable while it runs,
85
+ so keep it under ~1.2s and never use it on something the visitor must read
86
+ immediately. Persian and Arabic script scramble badly — the glyph shapes are
87
+ contextual — so use a Latin-only charset or skip the effect for those locales.
88
+
89
+ ## TextPlugin
90
+
91
+ Types or replaces text content. Simpler than scramble, and better for a
92
+ typewriter:
93
+
94
+ ```ts
95
+ gsap.to(el, { duration: 2, text: "Built in Iran.", ease: "none" });
96
+ ```
97
+
98
+ ## Counters
99
+
100
+ Numbers are text that should animate. Tween an object, format on update:
101
+
102
+ ```ts
103
+ const n = { value: 0 };
104
+ gsap.to(n, {
105
+ value: 1240,
106
+ duration: 1.4,
107
+ ease: "power2.out",
108
+ onUpdate: () => { el.textContent = Math.round(n.value).toLocaleString(locale); },
109
+ });
110
+ ```
111
+
112
+ Use tabular figures (`font-variant-numeric: tabular-nums`) or the width jitters
113
+ on every frame.
114
+
115
+ ## Without SplitText
116
+
117
+ A line mask needs no plugin when you control the markup — cheaper and no
118
+ revert to forget:
119
+
120
+ ```tsx
121
+ <span className="block overflow-hidden">
122
+ <span data-line className="block">…</span>
123
+ </span>
124
+ ```
125
+
126
+ ```ts
127
+ gsap.from("[data-line]", { yPercent: 110, stagger: 0.08, ease: "power3.out" });
128
+ ```
129
+
130
+ For a heading whose lines you know, this is the better answer.
131
+
132
+ ## Rhythm
133
+
134
+ - Lines: `stagger: 0.06–0.1`, duration `0.6–0.9`, `power3.out`.
135
+ - Words: `stagger: 0.03–0.05` — words are small, so the wave should be tight.
136
+ - Chars: `stagger: { amount: 0.4 }` — a *total*, so a long word does not crawl.
137
+
138
+ Text reveals should be over inside a second. Anything slower and the visitor is
139
+ waiting on the page rather than reading it.
140
+
141
+ ## Copy, locales and direction
142
+
143
+ Keep the words out of the animation. The animated component should receive the
144
+ **string as a prop**: a client component that imports the project's messages or
145
+ content module itself can bundle every locale into the browser payload. The
146
+ project's rules say where copy comes from — see
147
+ [project-rules.md](project-rules.md).
148
+
149
+ In a right-to-left language, a stagger in "reading order" is `from: "end"`, and
150
+ a line rising from the start edge uses logical properties, not `left`.