@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.
- package/LICENSE +21 -0
- package/NOTICE.md +24 -0
- package/README.md +59 -0
- package/bin/gsap-motion.mjs +304 -0
- package/package.json +48 -0
- package/skill/LICENSE +21 -0
- package/skill/NOTICE.md +24 -0
- package/skill/SKILL.md +181 -0
- package/skill/example/cinematic-intro.md +202 -0
- package/skill/example/magnetic-button.md +101 -0
- package/skill/example/svg-loader.md +180 -0
- package/skill/preset/card-stack.md +175 -0
- package/skill/preset/cinematic.md +140 -0
- package/skill/preset/cursor.md +184 -0
- package/skill/preset/loader.md +172 -0
- package/skill/preset/magnetic.md +155 -0
- package/skill/preset/marquee.md +177 -0
- package/skill/preset/page-transition.md +151 -0
- package/skill/preset/reveal.md +146 -0
- package/skill/preset/scroll-storytelling.md +176 -0
- package/skill/reference/accessibility.md +143 -0
- package/skill/reference/core-gsap.md +233 -0
- package/skill/reference/flip.md +220 -0
- package/skill/reference/frameworks.md +153 -0
- package/skill/reference/interaction.md +183 -0
- package/skill/reference/motion-design.md +144 -0
- package/skill/reference/performance.md +216 -0
- package/skill/reference/project-rules.md +178 -0
- package/skill/reference/react-nextjs.md +234 -0
- package/skill/reference/routing.md +119 -0
- package/skill/reference/scrolltrigger.md +191 -0
- package/skill/reference/svg.md +201 -0
- package/skill/reference/text.md +150 -0
- package/skill/reference/three-r3f.md +161 -0
- package/skill/reference/timeline.md +147 -0
- package/skill/scripts/audit-gsap.mjs +157 -0
- package/skill/scripts/audit-svg.mjs +358 -0
- package/skill/scripts/lib/rules.mjs +555 -0
- package/skill/scripts/lib/source.mjs +238 -0
- package/skill/template/useScrollScene.ts +126 -0
- 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`.
|