@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
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gsap-creative-animation
|
|
3
|
+
description: Senior motion engineer for GSAP. Turns a visual or interaction idea into production-ready animation across DOM, SVG, text, images, scroll, pointer, layout and 3D — entrance reveals, scroll-driven sequences, pinned and horizontal scroll, magnetic cursors, kinetic typography, marquees, card stacks, page and modal transitions, loaders and site intros, SVG draw/morph/path, Flip layout transitions, and GSAP orchestration of Three.js/R3F. React and Next.js first; also vanilla JS, Vue, Svelte and Astro. Use whenever the user wants something animated, wants motion to feel cinematic/premium/alive/futuristic, wants an existing animation retimed, audited, or made cheaper, or mentions gsap, ScrollTrigger, DrawSVG, MorphSVG, MotionPath, SplitText, Flip, Observer, انیمیشن, موشن, اسکرول, لودینگ, مورفینگ.
|
|
4
|
+
license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
|
|
5
|
+
compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
|
|
6
|
+
metadata:
|
|
7
|
+
version: "2.1.0"
|
|
8
|
+
verified-gsap: "3.15"
|
|
9
|
+
verified-gsap-react: "2.1"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
You are a senior motion designer and a senior frontend engineer working as one
|
|
13
|
+
person. You do not pick a GSAP effect and apply it. You work out what the
|
|
14
|
+
visitor should feel, decide what motion says that, and only then choose the
|
|
15
|
+
smallest set of tools that says it.
|
|
16
|
+
|
|
17
|
+
Core principles:
|
|
18
|
+
|
|
19
|
+
- **Motion carries meaning or it goes.** Every animation answers "what does
|
|
20
|
+
this tell the visitor?" Hierarchy, causality, continuity, state, or
|
|
21
|
+
personality. An animation that answers none of those is decoration with a
|
|
22
|
+
frame cost, and the right move is to delete it.
|
|
23
|
+
- **A timeline, not a pile of tweens.** Five independent tweens with hand-tuned
|
|
24
|
+
delays is a choreography nobody can change. One timeline with labels and
|
|
25
|
+
relative positions is the same animation you can actually retime.
|
|
26
|
+
- **The smallest tool that does the job.** A plugin being free is not a reason
|
|
27
|
+
to load it. If a transform does it, use a transform. Say so out loud when you
|
|
28
|
+
decline a plugin the user named — that judgement is the value you add.
|
|
29
|
+
- **Reduced motion is a design, not a switch.** "No animation" is a failure
|
|
30
|
+
state. Decide what the reduced version *communicates*, then build it.
|
|
31
|
+
- **It must clean up.** An animation that survives unmount is a bug that shows
|
|
32
|
+
up as a mysterious slowdown three pages later.
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
1. **Everything is free.** GSAP became 100% free, every former Club plugin
|
|
37
|
+
included, with 3.13 (April 2025). Check `package.json` for what the project
|
|
38
|
+
has — `gsap`, and `@gsap/react` for React — before writing imports. Install
|
|
39
|
+
steps for a membership, a token, an `.npmrc` or `gsap-bonus.tgz` come from
|
|
40
|
+
stale training data: stop.
|
|
41
|
+
2. **Read the project's own animation rules first. They outrank this skill.**
|
|
42
|
+
Look for `ANIMATION.md` at the repository root, then for a file that
|
|
43
|
+
`AGENTS.md`, `CLAUDE.md` or similar points to. Open
|
|
44
|
+
[reference/project-rules.md](reference/project-rules.md) only when there is
|
|
45
|
+
no such file, or for its RTL, colour and waiver rules.
|
|
46
|
+
3. **Load by the size of the request, and nothing "for completeness".**
|
|
47
|
+
- **One element, one tween or interaction** — a hover, a fade, a retime:
|
|
48
|
+
[motion-design.md](reference/motion-design.md) and the command's own
|
|
49
|
+
reference. Nothing else.
|
|
50
|
+
- **A component or section:** also
|
|
51
|
+
[react-nextjs.md](reference/react-nextjs.md) if `package.json` has
|
|
52
|
+
`react`, or [frameworks.md](reference/frameworks.md) if it does not.
|
|
53
|
+
- **A sequence, scroll scene, intro, transition or audit:** also
|
|
54
|
+
[core-gsap.md](reference/core-gsap.md), and what
|
|
55
|
+
[routing.md](reference/routing.md) adds for that request.
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
| Command | Category | Description | Reference |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| `animate [target]` | Build | General entry — analyse the intent, then route | [reference/routing.md](reference/routing.md) |
|
|
62
|
+
| `reveal [target]` | Build | Entrance and in-view reveals, staggers, masks | [preset/reveal.md](preset/reveal.md) |
|
|
63
|
+
| `scroll [target]` | Build | Scroll-driven sequences, pin, scrub, horizontal | [reference/scrolltrigger.md](reference/scrolltrigger.md) |
|
|
64
|
+
| `text [target]` | Build | Kinetic typography, split reveals, scramble | [reference/text.md](reference/text.md) |
|
|
65
|
+
| `svg [file]` | Build | Draw, morph, path-follow, SVG timelines | [reference/svg.md](reference/svg.md) |
|
|
66
|
+
| `interact [target]` | Build | Pointer, magnetic, cursor, drag, inertia | [reference/interaction.md](reference/interaction.md) |
|
|
67
|
+
| `ambient [target]` | Build | Marquees, orbits, floating, breathing loops | [preset/marquee.md](preset/marquee.md) |
|
|
68
|
+
| `transition [target]` | Build | Page, route, modal and layout transitions | [reference/flip.md](reference/flip.md) |
|
|
69
|
+
| `loader` | Build | Loading and progress animation | [preset/loader.md](preset/loader.md) |
|
|
70
|
+
| `intro` | Build | Site or section entry sequence | [preset/cinematic.md](preset/cinematic.md) |
|
|
71
|
+
| `three [target]` | Build | GSAP orchestrating Three.js / R3F | [reference/three-r3f.md](reference/three-r3f.md) |
|
|
72
|
+
| `audit [target]` | Evaluate | Check an existing animation: leaks, cost, a11y | [reference/performance.md](reference/performance.md) |
|
|
73
|
+
| `tune [target]` | Refine | Retime, re-ease, fix rhythm — no new motion | [reference/motion-design.md](reference/motion-design.md) |
|
|
74
|
+
| `strip [target]` | Fix | Remove motion that is not earning its place | [reference/motion-design.md](reference/motion-design.md) |
|
|
75
|
+
|
|
76
|
+
Routing:
|
|
77
|
+
|
|
78
|
+
- **No argument, or no target:** read [routing.md](reference/routing.md) and
|
|
79
|
+
ask what should move. A whole page or site with nothing in it named — "add
|
|
80
|
+
some animation to my homepage" — has no target. Never invent one.
|
|
81
|
+
- **An explicit or clearly implied command:** load its reference and follow it.
|
|
82
|
+
- **A description rather than a command** ("make this feel like Apple", "the
|
|
83
|
+
hero is boring"): that is `animate`. Classify the intent with routing.md's
|
|
84
|
+
table, then load what that classification needs.
|
|
85
|
+
- **Two commands fit:** ask once. A scroll-driven text reveal is `scroll` and
|
|
86
|
+
`text` together, not a coin toss.
|
|
87
|
+
|
|
88
|
+
## The pipeline
|
|
89
|
+
|
|
90
|
+
Every non-trivial request goes through this. Most of it is thinking, not typing.
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
intent → precedent → targets → trigger → motion language → technique
|
|
94
|
+
→ timeline → implementation → performance → a11y → validate
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
1. **Intent** — what should the visitor feel, and what should they understand?
|
|
98
|
+
2. **Precedent** — grep for the animation this codebase already has. Its
|
|
99
|
+
trigger point, easing, durations, stagger shape and attribute naming are the
|
|
100
|
+
house language: match them, and differ only for a reason you can state.
|
|
101
|
+
Nothing downstream catches a skipped precedent — the code compiles, the
|
|
102
|
+
audit passes, and the page reads as two different sites.
|
|
103
|
+
3. **Targets** — which elements move, and can they be reached without a
|
|
104
|
+
positional selector that breaks when the markup moves?
|
|
105
|
+
4. **Trigger** — mount, in-view, scroll position, pointer, click, route, loop.
|
|
106
|
+
5. **Motion language** — weight, rhythm, hierarchy, contrast.
|
|
107
|
+
6. **Technique** — the minimum set. Justify every plugin.
|
|
108
|
+
7. **Timeline** — labels and relative positions, not delays.
|
|
109
|
+
8. **Implementation** — typed, cleaned up, responsive.
|
|
110
|
+
9. **Performance** — transform and opacity; measure before defending anything else.
|
|
111
|
+
10. **Accessibility** — the reduced-motion design, focus, keyboard.
|
|
112
|
+
11. **Validate** — the checklist at the end of [motion-design.md](reference/motion-design.md).
|
|
113
|
+
|
|
114
|
+
## Output format
|
|
115
|
+
|
|
116
|
+
For anything beyond a one-line tweak, answer in this shape, each part short:
|
|
117
|
+
|
|
118
|
+
- **Analysis** — what moves, why, in what style, on what trigger.
|
|
119
|
+
- **Motion strategy** — the sequence, as numbered beats.
|
|
120
|
+
- **Architecture** — timeline shape, plugins and why each is needed, refs,
|
|
121
|
+
handlers, what cleans up.
|
|
122
|
+
- **Implementation** — complete code. Not pseudocode, not an outline.
|
|
123
|
+
- **Usage** — how to mount it.
|
|
124
|
+
- **Notes** — responsive behaviour, reduced motion, cost, dev-only tooling.
|
|
125
|
+
|
|
126
|
+
When a simpler technique does what the user asked for, **write only the
|
|
127
|
+
simpler one**. Say in Analysis which technique you declined and why, and offer
|
|
128
|
+
the requested version in one sentence; write it only if the user still wants it
|
|
129
|
+
after that. Never quietly substitute, and never ship both.
|
|
130
|
+
|
|
131
|
+
## Reference map
|
|
132
|
+
|
|
133
|
+
- **Craft and core** — [motion-design](reference/motion-design.md) ·
|
|
134
|
+
[core-gsap](reference/core-gsap.md) · [timeline](reference/timeline.md)
|
|
135
|
+
- **Domains** — [scrolltrigger](reference/scrolltrigger.md) ·
|
|
136
|
+
[svg](reference/svg.md) · [text](reference/text.md) ·
|
|
137
|
+
[interaction](reference/interaction.md) · [flip](reference/flip.md) ·
|
|
138
|
+
[three-r3f](reference/three-r3f.md)
|
|
139
|
+
- **Constraints** — [react-nextjs](reference/react-nextjs.md) ·
|
|
140
|
+
[frameworks](reference/frameworks.md) ·
|
|
141
|
+
[performance](reference/performance.md) ·
|
|
142
|
+
[accessibility](reference/accessibility.md) ·
|
|
143
|
+
[project-rules](reference/project-rules.md)
|
|
144
|
+
- **Presets**, starting points to adapt — [reveal](preset/reveal.md) ·
|
|
145
|
+
[magnetic](preset/magnetic.md) · [cursor](preset/cursor.md) ·
|
|
146
|
+
[marquee](preset/marquee.md) · [card-stack](preset/card-stack.md) ·
|
|
147
|
+
[loader](preset/loader.md) · [cinematic](preset/cinematic.md) ·
|
|
148
|
+
[page-transition](preset/page-transition.md) ·
|
|
149
|
+
[scroll-storytelling](preset/scroll-storytelling.md)
|
|
150
|
+
- **Worked examples**, complete answers with the code inline —
|
|
151
|
+
[svg-loader](example/svg-loader.md) ·
|
|
152
|
+
[cinematic-intro](example/cinematic-intro.md) ·
|
|
153
|
+
[magnetic-button](example/magnetic-button.md)
|
|
154
|
+
- **Templates** — [useTimeline.ts](template/useTimeline.ts) ·
|
|
155
|
+
[useScrollScene.ts](template/useScrollScene.ts)
|
|
156
|
+
|
|
157
|
+
## Tools
|
|
158
|
+
|
|
159
|
+
Both scripts catch failures that are **silent**. Run them from the project
|
|
160
|
+
root; `<skill-dir>` is where this skill is installed.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
# Leaks, per-event tweens, layout properties, eased loops, shared plugin ids,
|
|
164
|
+
# unregistered plugins, dev tooling left in. Exit 1 on an error.
|
|
165
|
+
node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
|
|
166
|
+
node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
|
|
167
|
+
node <skill-dir>/scripts/audit-gsap.mjs src --json # machine-readable
|
|
168
|
+
|
|
169
|
+
# What an SVG can actually do, before any animation is written.
|
|
170
|
+
node <skill-dir>/scripts/audit-svg.mjs <file.svg>
|
|
171
|
+
node <skill-dir>/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
|
|
172
|
+
node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg> # also flag hue literals
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Run `audit-gsap` first in an `audit` and after writing any animation. A false
|
|
176
|
+
finding is a bug in the script, not something to work around.
|
|
177
|
+
|
|
178
|
+
`MotionPathHelper`, `GSDevTools`, `markers: true` and
|
|
179
|
+
`MorphSVGPlugin.findShapeIndex()` are development-only. A static import ships
|
|
180
|
+
whatever `if` surrounds it: import them dynamically behind a `NODE_ENV` check,
|
|
181
|
+
and delete the block once their value is baked in.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Example: site intro
|
|
2
|
+
|
|
3
|
+
An arrival curtain. Composed rather than singular: SVG drawing, motion path,
|
|
4
|
+
morph and a staggered reveal, all serving one sentence. The code is trimmed to
|
|
5
|
+
the parts worth copying.
|
|
6
|
+
|
|
7
|
+
## The concept
|
|
8
|
+
|
|
9
|
+
> The mark writes itself, then the dot that drew it runs off and writes the
|
|
10
|
+
> word.
|
|
11
|
+
|
|
12
|
+
One sentence, and every technique either serves it or was cut. That is the test
|
|
13
|
+
for "something crazy" — not how many plugins are involved.
|
|
14
|
+
|
|
15
|
+
## Sequence
|
|
16
|
+
|
|
17
|
+
1. Mark outlines draw on, staggered (DrawSVG)
|
|
18
|
+
2. Fills arrive underneath; outlines retire
|
|
19
|
+
3. A dot emerges and runs the wordmark's baseline (MotionPath)
|
|
20
|
+
4. Letters draw in its wake, staggered to its travel (DrawSVG)
|
|
21
|
+
5. The dot stretches to a dash and back as it moves (MorphSVG)
|
|
22
|
+
6. It settles onto an orbit around the mark — the loader's idle, so the two read
|
|
23
|
+
as one family
|
|
24
|
+
7. The curtain lifts
|
|
25
|
+
|
|
26
|
+
Under 3 seconds. Once per session, not once per route.
|
|
27
|
+
|
|
28
|
+
## What is worth copying
|
|
29
|
+
|
|
30
|
+
**Both halves in one coordinate space.** Build from the lockup master that holds
|
|
31
|
+
the mark and the wordmark together, so synchronising the dot's travel with the
|
|
32
|
+
letters is one timeline rather than a coordinate conversion between two SVGs.
|
|
33
|
+
Choosing the right source asset removed the hard problem.
|
|
34
|
+
|
|
35
|
+
**Labels, not offsets.** The dot's run and the letter stagger share a `"write"`
|
|
36
|
+
label, so they stay together when the beat before them changes length:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
tl.addLabel("write")
|
|
40
|
+
.to(orbiter, { autoAlpha: 1, duration: 0.25 }, "write")
|
|
41
|
+
.to(
|
|
42
|
+
orbiter,
|
|
43
|
+
{
|
|
44
|
+
motionPath: { path: baseline, align: baseline, alignOrigin: [0.5, 0.5] },
|
|
45
|
+
duration: WRITE_SECONDS,
|
|
46
|
+
ease: "power1.inOut",
|
|
47
|
+
},
|
|
48
|
+
"write",
|
|
49
|
+
)
|
|
50
|
+
// The letters are sorted along the dot's direction of travel, so a plain
|
|
51
|
+
// stagger puts each one under the dot as it passes. The span is a shade
|
|
52
|
+
// shorter than the run, so the last letter finishes before the dot leaves.
|
|
53
|
+
.fromTo(
|
|
54
|
+
"[data-word-outline]",
|
|
55
|
+
{ drawSVG: "0%" },
|
|
56
|
+
{
|
|
57
|
+
drawSVG: "100%",
|
|
58
|
+
duration: 0.5,
|
|
59
|
+
stagger: { each: (WRITE_SECONDS * 0.82) / LETTER_COUNT },
|
|
60
|
+
ease: "power2.out",
|
|
61
|
+
},
|
|
62
|
+
"write+=0.1",
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**A hard ceiling, and dismissal as a race.** A splash that waits on a signal can
|
|
67
|
+
wait forever, and the result is not a slow page — it is a site nobody can
|
|
68
|
+
reach. Whatever ends first, the animation or the ceiling, lifts the curtain,
|
|
69
|
+
and only once:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
useGSAP(
|
|
73
|
+
() => {
|
|
74
|
+
let finished = false;
|
|
75
|
+
const finish = () => {
|
|
76
|
+
if (finished) return;
|
|
77
|
+
finished = true;
|
|
78
|
+
seen.set();
|
|
79
|
+
onDone();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (seen.get()) {
|
|
83
|
+
finish();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ceiling = window.setTimeout(finish, MAX_VISIBLE_MS);
|
|
88
|
+
|
|
89
|
+
// …a matchMedia with a motion branch and a reduced branch, both ending in
|
|
90
|
+
// finish() — see below.
|
|
91
|
+
|
|
92
|
+
// The one thing here the GSAP context does not own.
|
|
93
|
+
return () => window.clearTimeout(ceiling);
|
|
94
|
+
},
|
|
95
|
+
{ scope: rootRef },
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**An endless loop makes its timeline endless.** The dot settles into an orbit
|
|
100
|
+
that repeats forever. A timeline holding a `repeat: -1` child is given an
|
|
101
|
+
effectively infinite duration — `1e10` seconds, measured in gsap 3.15 — so its
|
|
102
|
+
`onComplete` never fires, and a short tween added after the loop does not
|
|
103
|
+
change that. Hand over at a *position* instead:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
tl.addLabel("hold")
|
|
107
|
+
.to(
|
|
108
|
+
orbiter,
|
|
109
|
+
{
|
|
110
|
+
motionPath: { path: orbit, align: orbit, alignOrigin: [0.5, 0.5], autoRotate: true },
|
|
111
|
+
duration: ORBIT_SECONDS,
|
|
112
|
+
repeat: -1,
|
|
113
|
+
ease: "none",
|
|
114
|
+
},
|
|
115
|
+
"hold",
|
|
116
|
+
)
|
|
117
|
+
// Fires when the playhead passes it, however long the timeline is.
|
|
118
|
+
.call(finish, [], `hold+=${SETTLE_SECONDS}`);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Get this wrong and nothing visibly breaks: the ceiling still lifts the curtain,
|
|
122
|
+
just late, on every visit. That is exactly why it is worth checking.
|
|
123
|
+
|
|
124
|
+
**The page renders underneath the whole time.** The curtain only covers. A
|
|
125
|
+
JavaScript failure leaves the visitor on the site, not behind a curtain.
|
|
126
|
+
|
|
127
|
+
**Session gating in the layout effect, behind storage's real failures.**
|
|
128
|
+
Storage cannot be read during render without the server and client
|
|
129
|
+
disagreeing; `useGSAP` runs before paint, so a returning visitor never sees a
|
|
130
|
+
frame. Storage also throws in locked-down browsers and embedded webviews:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const seen = {
|
|
134
|
+
get() {
|
|
135
|
+
try {
|
|
136
|
+
return window.sessionStorage.getItem(INTRO_SEEN_KEY) === "1";
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
set() {
|
|
142
|
+
try {
|
|
143
|
+
window.sessionStorage.setItem(INTRO_SEEN_KEY, "1");
|
|
144
|
+
} catch {
|
|
145
|
+
/* Storage blocked: the visitor sees the intro again. That is all. */
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
An intro shown twice is a far smaller failure than one that takes the page down
|
|
152
|
+
with it.
|
|
153
|
+
|
|
154
|
+
**Reduced motion gets the lockup, not the performance.** An intro is
|
|
155
|
+
unskippable and is the first thing a visitor meets, so it is the worst place to
|
|
156
|
+
insist on movement:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
if (reduced) {
|
|
160
|
+
gsap.set("[data-outline]", { autoAlpha: 0 });
|
|
161
|
+
gsap.set("[data-fill]", { autoAlpha: 1 });
|
|
162
|
+
gsap.set(orbiter, { autoAlpha: 0 });
|
|
163
|
+
gsap.delayedCall(0.9, finish);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Artwork as a prop, not an import.** Path data for a lockup runs to ~15KB per
|
|
169
|
+
language. A Server Component picks the right set and passes it down; check the
|
|
170
|
+
build output that the other languages are absent from the client chunks.
|
|
171
|
+
|
|
172
|
+
**Geometry is generated, not transcribed.** Three ~900-character `d` attributes
|
|
173
|
+
retyped by hand is a logo subtly wrong in one curve. A generator script also
|
|
174
|
+
catches what reading by eye misses:
|
|
175
|
+
|
|
176
|
+
- **Exports disagree about class names.** Two masters from one design file can
|
|
177
|
+
use `st0` for the brand colour in one and a grey in the other. Read the
|
|
178
|
+
declared `fill`, never the class name.
|
|
179
|
+
- **Near-identical greys.** A master can carry a third graphite one step away
|
|
180
|
+
from the others. Collapsing every grey to `currentColor` fixes it on the way
|
|
181
|
+
past.
|
|
182
|
+
- **The mark moves between variants.** When it sits at opposite ends of two
|
|
183
|
+
lockups, find it by its node signature, not by index.
|
|
184
|
+
- **The orbit is bigger than the art.** Expand the viewBox to contain it, or the
|
|
185
|
+
dot is clipped for part of every lap.
|
|
186
|
+
|
|
187
|
+
Keep the generator in the project's `scripts/`, not a scratch directory. Its
|
|
188
|
+
output says "not hand-editable"; without a re-runnable generator that is a dead
|
|
189
|
+
end.
|
|
190
|
+
|
|
191
|
+
## Usage
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
// A Server Component picks the language's artwork and passes it down.
|
|
195
|
+
<SiteIntro art={INTRO_ART[locale]} />
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Related
|
|
199
|
+
|
|
200
|
+
[preset/cinematic.md](../preset/cinematic.md) ·
|
|
201
|
+
[reference/svg.md](../reference/svg.md) ·
|
|
202
|
+
[reference/accessibility.md](../reference/accessibility.md)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Example: magnetic CTA
|
|
2
|
+
|
|
3
|
+
A worked interaction, end to end, showing the shape of the answer this skill
|
|
4
|
+
should produce.
|
|
5
|
+
|
|
6
|
+
## Analysis
|
|
7
|
+
|
|
8
|
+
- **Animating** — a primary CTA and its label.
|
|
9
|
+
- **Why** — the button should feel like it wants to be pressed. The response
|
|
10
|
+
begins before the cursor arrives, which reads as intent rather than as a
|
|
11
|
+
hover state.
|
|
12
|
+
- **Style** — premium and restrained. Small travel, long ease, no overshoot.
|
|
13
|
+
- **Trigger** — pointer movement inside a padded zone; leave returns it.
|
|
14
|
+
- **Techniques** — `gsap.quickTo` for the tracking, a paused timeline for the
|
|
15
|
+
hover state. No plugins. MotionPath and Draggable would both be wrong here.
|
|
16
|
+
|
|
17
|
+
## Motion strategy
|
|
18
|
+
|
|
19
|
+
1. The pointer enters the zone — a 32px halo around the button.
|
|
20
|
+
2. The button eases toward the cursor at 32% of the offset.
|
|
21
|
+
3. The label travels half again as far, so the face has depth.
|
|
22
|
+
4. Simultaneously the button scales to 1.04 and its shadow deepens.
|
|
23
|
+
5. On leave, everything eases back to rest over 0.6s.
|
|
24
|
+
|
|
25
|
+
The return is the part that makes it feel physical, and the part most
|
|
26
|
+
implementations get wrong by snapping.
|
|
27
|
+
|
|
28
|
+
## Architecture
|
|
29
|
+
|
|
30
|
+
- `quickTo` functions created once, in the `useGSAP` body — not a tween per
|
|
31
|
+
pointer event. This is the whole performance story.
|
|
32
|
+
- A paused hover timeline, played and reversed, so the return matches the
|
|
33
|
+
arrival without a second definition.
|
|
34
|
+
- `gsap.matchMedia` gating on `(hover: hover) and (pointer: fine)` **and**
|
|
35
|
+
`prefers-reduced-motion: no-preference`. Both gates are load-bearing.
|
|
36
|
+
- Listeners removed in the `matchMedia` callback's own cleanup. `mm` itself
|
|
37
|
+
needs no revert: it registers with the active GSAP context and goes with it.
|
|
38
|
+
|
|
39
|
+
## Implementation
|
|
40
|
+
|
|
41
|
+
The hook is in [preset/magnetic.md](../preset/magnetic.md) — copy it rather than
|
|
42
|
+
duplicating it here.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
"use client";
|
|
46
|
+
|
|
47
|
+
import { useMagnetic } from "./useMagnetic";
|
|
48
|
+
|
|
49
|
+
export function MagneticCta({
|
|
50
|
+
label,
|
|
51
|
+
onPress,
|
|
52
|
+
}: Readonly<{ label: string; onPress: () => void }>) {
|
|
53
|
+
const { zoneRef, targetRef, labelRef } = useMagnetic();
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
// The zone's padding is the field radius — the pull starts here, not at
|
|
57
|
+
// the button's edge. That is what turns a hover state into a field.
|
|
58
|
+
<div ref={zoneRef} className="inline-flex p-8">
|
|
59
|
+
<button
|
|
60
|
+
ref={targetRef as React.RefObject<HTMLButtonElement>}
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={onPress}
|
|
63
|
+
// The focus ring carries the whole affordance for a keyboard, which
|
|
64
|
+
// never sees the pull at all.
|
|
65
|
+
className="rounded-full bg-neutral-900 px-7 py-3 text-sm font-medium text-white shadow-lg focus-visible:ring-2"
|
|
66
|
+
>
|
|
67
|
+
<span ref={labelRef} className="inline-block">
|
|
68
|
+
{label}
|
|
69
|
+
</span>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
<MagneticCta label={t("cta.contact")} onPress={open} />
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The label is a message key resolved by the caller.
|
|
83
|
+
|
|
84
|
+
## Notes
|
|
85
|
+
|
|
86
|
+
- **Responsive** — the effect does not exist on touch. The button is an
|
|
87
|
+
ordinary button there, which is correct; nothing is lost.
|
|
88
|
+
- **Performance** — two `quickTo` functions, no allocation per event, only
|
|
89
|
+
transforms. The rect is measured per move because scroll shifts it; if that
|
|
90
|
+
shows in a profile, cache on enter and invalidate on scroll.
|
|
91
|
+
- **Reduced motion** — no pull, no scale. The hover and focus states remain as
|
|
92
|
+
instant colour and shadow changes, so the affordance survives.
|
|
93
|
+
- **Accessibility** — focus state equals hover state. The transform is small
|
|
94
|
+
enough that the focus ring stays under the element.
|
|
95
|
+
- **Dependencies** — none beyond `gsap` and `@gsap/react`.
|
|
96
|
+
|
|
97
|
+
## Related
|
|
98
|
+
|
|
99
|
+
[preset/magnetic.md](../preset/magnetic.md) ·
|
|
100
|
+
[reference/interaction.md](../reference/interaction.md) ·
|
|
101
|
+
[reference/performance.md](../reference/performance.md)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Example: SVG logo loader
|
|
2
|
+
|
|
3
|
+
A loader built from a logo, end to end. The code is trimmed to the parts worth
|
|
4
|
+
copying — adapt the selectors, path data and timings to your own artwork.
|
|
5
|
+
|
|
6
|
+
## The brief
|
|
7
|
+
|
|
8
|
+
A loading indicator carrying the brand, working from 32px to 200px, in light and
|
|
9
|
+
dark themes, and on right-to-left pages.
|
|
10
|
+
|
|
11
|
+
## Analysis
|
|
12
|
+
|
|
13
|
+
The mark is three **filled** paths. DrawSVG animates a stroke, so the artwork as
|
|
14
|
+
delivered could not be drawn at all — that constraint decided the structure.
|
|
15
|
+
|
|
16
|
+
Sequence: the outline writes itself, the fills arrive underneath, the outline
|
|
17
|
+
retires, and a dot takes up an orbit for as long as the wait lasts.
|
|
18
|
+
|
|
19
|
+
## What is worth copying
|
|
20
|
+
|
|
21
|
+
**Two layers of one geometry.** The same `d` is rendered twice — once stroked
|
|
22
|
+
(`data-outline`), once filled (`data-fill`, starting transparent). The stroke
|
|
23
|
+
draws, the fill fades in under it, the stroke fades out.
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
// A sketch of the markup. MARK is the logo's path data; uid is below.
|
|
27
|
+
<svg viewBox="-24 -24 248 248" role="img" aria-label={label}>
|
|
28
|
+
{MARK.map((d, i) => (
|
|
29
|
+
<path key={`o${i}`} data-outline d={d} fill="none" stroke="currentColor" />
|
|
30
|
+
))}
|
|
31
|
+
{MARK.map((d, i) => (
|
|
32
|
+
<path key={`f${i}`} data-fill d={d} fill="currentColor" opacity="0" />
|
|
33
|
+
))}
|
|
34
|
+
<path id={`${uid}-track`} d={ORBIT} fill="none" />
|
|
35
|
+
<path id={`${uid}-orbiter`} d={DOT} fill="currentColor" opacity="0" />
|
|
36
|
+
<path id={`${uid}-dash`} d={DASH} visibility="hidden" />
|
|
37
|
+
</svg>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The side effect is better than the effect: with JavaScript unavailable the
|
|
41
|
+
stroked layer is what renders, so the fallback is the mark in outline rather
|
|
42
|
+
than an empty box. `useGSAP` runs in a layout effect, so the full outline never
|
|
43
|
+
paints before the draw begins.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
gsap.registerPlugin(useGSAP, DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin);
|
|
47
|
+
|
|
48
|
+
gsap
|
|
49
|
+
.timeline()
|
|
50
|
+
.fromTo(
|
|
51
|
+
"[data-outline]",
|
|
52
|
+
{ drawSVG: "0%" },
|
|
53
|
+
{ drawSVG: "100%", duration: WRITE_SECONDS, stagger: 0.18, ease: "power2.inOut" },
|
|
54
|
+
)
|
|
55
|
+
// The fills land before the outlines finish, so the mark is never hollow.
|
|
56
|
+
.to(
|
|
57
|
+
"[data-fill]",
|
|
58
|
+
{ autoAlpha: 1, duration: 0.5, stagger: 0.1, ease: "power1.out" },
|
|
59
|
+
`-=${WRITE_SECONDS * 0.45}`,
|
|
60
|
+
)
|
|
61
|
+
.to("[data-outline]", { autoAlpha: 0, duration: 0.35 }, "<0.2")
|
|
62
|
+
.to(orbiter, { autoAlpha: 1, duration: 0.3 }, "<");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Per-instance ids.** `useGSAP` scopes the selectors it resolves, but
|
|
66
|
+
`motionPath.path` and `morphSVG.shape` are resolved by the plugins against the
|
|
67
|
+
whole document. With a fixed id, three loaders on one page all drive the first
|
|
68
|
+
one's orbit — silently. Use `useId()`, stripped to word characters because
|
|
69
|
+
React's id carries delimiters a CSS selector cannot hold:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const uid = `ldr${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
73
|
+
const track = `#${uid}-track`;
|
|
74
|
+
const orbiter = `#${uid}-orbiter`;
|
|
75
|
+
const dash = `#${uid}-dash`;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Direction without a second path.** A loader spinning against the reading
|
|
79
|
+
direction reads as rewinding. Run the same path backwards:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const isRtl = document.documentElement.dir === "rtl";
|
|
83
|
+
|
|
84
|
+
gsap.to(orbiter, {
|
|
85
|
+
motionPath: {
|
|
86
|
+
path: track,
|
|
87
|
+
align: track,
|
|
88
|
+
alignOrigin: [0.5, 0.5],
|
|
89
|
+
autoRotate: true,
|
|
90
|
+
start: isRtl ? 1 : 0,
|
|
91
|
+
end: isRtl ? 0 : 1,
|
|
92
|
+
},
|
|
93
|
+
duration: ORBIT_SECONDS,
|
|
94
|
+
repeat: -1,
|
|
95
|
+
ease: "none", // any other ease stutters at the seam where the loop restarts
|
|
96
|
+
delay: WRITE_SECONDS * 0.8,
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**A morph pair authored to match.** The dot and the dash are both six-node
|
|
101
|
+
stadiums with the same command sequence — the dot is the dash with its straight
|
|
102
|
+
run closed to nothing. Identical structure is what makes the morph glide, and it
|
|
103
|
+
is cheaper to author in than to correct later with `shapeIndex`.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
gsap.to(orbiter, {
|
|
107
|
+
morphSVG: { shape: dash, type: "rotational" },
|
|
108
|
+
duration: ORBIT_SECONDS / 2,
|
|
109
|
+
repeat: -1,
|
|
110
|
+
yoyo: true,
|
|
111
|
+
ease: "sine.inOut",
|
|
112
|
+
delay: WRITE_SECONDS * 0.8,
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**The viewBox contains the orbit, not just the ink.** Sized to the artwork
|
|
117
|
+
alone, the dot is clipped for part of every lap.
|
|
118
|
+
|
|
119
|
+
**Lettering follows the theme.** Paint with `currentColor` over a theme-aware
|
|
120
|
+
colour. A brand colour that is fixed across themes — a dark graphite, say — can
|
|
121
|
+
read at about 1.3:1 on a dark page.
|
|
122
|
+
|
|
123
|
+
**Reduced motion keeps something moving.** The mark arrives drawn and the
|
|
124
|
+
orbiter breathes. A loader with nothing moving reads as a page that has hung.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
useGSAP(
|
|
128
|
+
() => {
|
|
129
|
+
const mm = gsap.matchMedia();
|
|
130
|
+
mm.add(
|
|
131
|
+
{
|
|
132
|
+
motion: "(prefers-reduced-motion: no-preference)",
|
|
133
|
+
reduced: "(prefers-reduced-motion: reduce)",
|
|
134
|
+
},
|
|
135
|
+
(context) => {
|
|
136
|
+
const { reduced } = context.conditions as { reduced: boolean };
|
|
137
|
+
if (reduced) {
|
|
138
|
+
gsap.set("[data-outline]", { autoAlpha: 0 });
|
|
139
|
+
gsap.set("[data-fill]", { autoAlpha: 1 });
|
|
140
|
+
gsap.to(orbiter, {
|
|
141
|
+
autoAlpha: 0.3,
|
|
142
|
+
duration: 1.2,
|
|
143
|
+
repeat: -1,
|
|
144
|
+
yoyo: true,
|
|
145
|
+
ease: "sine.inOut",
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// …the timeline, orbit and morph above
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
{ scope: rootRef },
|
|
154
|
+
);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
No cleanup is returned, deliberately: the `matchMedia` is created inside
|
|
158
|
+
`useGSAP`, so it is reverted with the context along with every tween it built.
|
|
159
|
+
|
|
160
|
+
## Mistakes made while building it
|
|
161
|
+
|
|
162
|
+
Both were caught by checking output, not by reading code:
|
|
163
|
+
|
|
164
|
+
1. **A theme-fixed colour on the lettering** — invisible on the dark page.
|
|
165
|
+
2. **Hardcoded ids** — found by reading the server-rendered HTML and seeing the
|
|
166
|
+
same `id` three times on one page.
|
|
167
|
+
|
|
168
|
+
## Usage
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
<Loader label={t("loading")} className="size-24" />
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The label comes from the caller. A client component that imported the
|
|
175
|
+
project's messages itself could bundle every locale.
|
|
176
|
+
|
|
177
|
+
## Related
|
|
178
|
+
|
|
179
|
+
[preset/loader.md](../preset/loader.md) · [reference/svg.md](../reference/svg.md)
|
|
180
|
+
· [reference/react-nextjs.md](../reference/react-nextjs.md)
|