@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
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)