@mehshekari/gsap-motion 4.3.0 → 4.5.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/README.md +1 -1
- package/bin/gsap-motion.mjs +20 -2
- package/package.json +1 -1
- package/skill/SKILL.md +8 -8
- package/skill/adapter/next.md +1 -2
- package/skill/adapter/r3f.md +4 -1
- package/skill/adapter/react.md +8 -5
- package/skill/adapter/three.md +2 -0
- package/skill/example/magnetic-button.md +101 -101
- package/skill/preset/cursor.md +184 -184
- package/skill/preset/magnetic.md +155 -155
- package/skill/reference/devices.md +10 -0
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +1 -0
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +63 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +27 -3
- package/skill/scripts/capture-motion.mjs +248 -44
- package/skill/scripts/lib/ast.mjs +82 -12
- package/skill/scripts/lib/rules.mjs +311 -7
- package/skill/scripts/lib/source.mjs +9 -1
- package/skill/scripts/review-motion.mjs +224 -25
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
32
32
|
build without warning:
|
|
33
33
|
|
|
34
34
|
```json
|
|
35
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@4.
|
|
35
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@4.5.0 audit src --quiet"
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -74,15 +74,21 @@ Commands
|
|
|
74
74
|
--morph <a> <b> Compare a morph pair
|
|
75
75
|
--hues Also report hard-coded hues
|
|
76
76
|
inspect <url> Watch a page animate and report what it did
|
|
77
|
-
--at 0,300,900 When to capture, in ms after
|
|
77
|
+
--at 0,300,900 When to capture, in ms after --wait
|
|
78
|
+
--wait 400 A fixed wait after navigation; it does not detect a loader
|
|
78
79
|
--scroll <px|sel> Scroll before capturing
|
|
79
80
|
--hover <selector> Move the pointer onto an element
|
|
80
81
|
--click <selector> Click an element
|
|
81
82
|
--reduced --dark Emulate a preference
|
|
82
83
|
--mobile 390x844 with touch input
|
|
84
|
+
--viewport 1440x900 The window's size; every report prints the one used
|
|
83
85
|
--cpu 4 Throttle the CPU by this factor
|
|
86
|
+
--headed A visible Chrome on the GPU, for WebGL pages
|
|
87
|
+
--seek <name> Call window.<name>(t) before each capture; --at is
|
|
88
|
+
then seconds of the timeline
|
|
84
89
|
--out <dir> Where frames go. Default .gsap-motion/inspect
|
|
85
90
|
--json Machine-readable output
|
|
91
|
+
--probe timeline Print the timeline reader for a browser you drive
|
|
86
92
|
explain <file...> Map an animation: scopes, branches, beats, triggers
|
|
87
93
|
Give it a URL instead to read the running page
|
|
88
94
|
--json Machine-readable output
|
|
@@ -92,9 +98,13 @@ Commands
|
|
|
92
98
|
--scroll <px|sel> Scroll, then watch again
|
|
93
99
|
--reduced --dark Emulate a preference
|
|
94
100
|
--mobile 390x844 with touch input
|
|
101
|
+
--viewport 1440x900 The window's size; every report prints the one used
|
|
95
102
|
--cpu 4 Throttle the CPU by this factor
|
|
103
|
+
--headed A visible Chrome on the GPU, for WebGL pages
|
|
96
104
|
--out <dir> Save a frame alongside the measurements
|
|
97
105
|
--json Machine-readable output
|
|
106
|
+
--probe install|read Print the recorder, or its findings, for a browser
|
|
107
|
+
you drive — such as a DevTools MCP server
|
|
98
108
|
patterns check [file] Check the Patterns section of your ANIMATION.md:
|
|
99
109
|
the fields each status needs, the uses behind
|
|
100
110
|
validated, the reviewer behind canonical
|
|
@@ -290,7 +300,15 @@ function doctor(args = []) {
|
|
|
290
300
|
if (provider) report("info", `No separate copy for ${scope}`);
|
|
291
301
|
else report("info", `Not installed for ${scope}`, `${command} installs it at ${location}`);
|
|
292
302
|
} else if (installed.version === bundled.version) {
|
|
293
|
-
|
|
303
|
+
if (provider) {
|
|
304
|
+
report(
|
|
305
|
+
"warn",
|
|
306
|
+
`Installed for ${scope}: ${installed.version}`,
|
|
307
|
+
`The ${provider} plugin provides it too, so the skill is listed twice. Remove ${location}.`,
|
|
308
|
+
);
|
|
309
|
+
} else {
|
|
310
|
+
report("ok", `Installed for ${scope}: ${installed.version}`);
|
|
311
|
+
}
|
|
294
312
|
} else {
|
|
295
313
|
report(
|
|
296
314
|
"warn",
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
|
|
|
4
4
|
license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
|
|
5
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 22+ and nothing else.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "4.
|
|
7
|
+
version: "4.5.0"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
|
@@ -75,8 +75,8 @@ Core principles:
|
|
|
75
75
|
- **A command, explicit or implied:** load its reference and follow it.
|
|
76
76
|
- **A description** ("make this feel like Apple", "the hero is boring"): that is
|
|
77
77
|
`create`. Classify with routing.md's table, then load what that needs.
|
|
78
|
-
- **Two commands fit:**
|
|
79
|
-
`text` together
|
|
78
|
+
- **Two commands fit:** use both, and say so in a line. A scroll-driven text
|
|
79
|
+
reveal is `scroll` and `text` together.
|
|
80
80
|
- **`design` stops at the plan.** `build` implements one that was approved, and
|
|
81
81
|
needs it in hand. `evolve` improves what is there and never adds motion.
|
|
82
82
|
|
|
@@ -109,7 +109,8 @@ intent → precedent → targets → trigger → motion language → technique
|
|
|
109
109
|
|
|
110
110
|
## Output format
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
Beyond a one-line tweak, use this shape, each part short, unless the user or
|
|
113
|
+
project asks for another:
|
|
113
114
|
|
|
114
115
|
- **Analysis** — what moves, why, on what trigger, and the intent you read it
|
|
115
116
|
as, with the values that follow.
|
|
@@ -160,8 +161,7 @@ These catch what is **silent**. Run them from the project root; `<skill-dir>`
|
|
|
160
161
|
is the skill's folder.
|
|
161
162
|
|
|
162
163
|
```bash
|
|
163
|
-
#
|
|
164
|
-
# plugin ids, unregistered plugins, dev tooling. Exit 1 on an error.
|
|
164
|
+
# Silent failures: leaks, per-frame cost, plugins, dev tooling. Exit 1 on error.
|
|
165
165
|
node <skill-dir>/scripts/audit-gsap.mjs [path...] # default: src
|
|
166
166
|
node <skill-dir>/scripts/audit-gsap.mjs src --quiet # errors only
|
|
167
167
|
node <skill-dir>/scripts/audit-gsap.mjs src --json
|
|
@@ -171,8 +171,8 @@ node <skill-dir>/scripts/audit-svg.mjs <file.svg>
|
|
|
171
171
|
node <skill-dir>/scripts/audit-svg.mjs --morph <a.svg> <b.svg>
|
|
172
172
|
node <skill-dir>/scripts/audit-svg.mjs --hues <file.svg>
|
|
173
173
|
|
|
174
|
-
# Both
|
|
175
|
-
# first motion, screen share, seams, motion over text.
|
|
174
|
+
# Both open a Chrome (refine.md: --probe, for yours). Watch a page animate,
|
|
175
|
+
# then measure: first motion, screen share, seams, motion over text.
|
|
176
176
|
node <skill-dir>/scripts/capture-motion.mjs <url> --at 0,300,900
|
|
177
177
|
node <skill-dir>/scripts/review-motion.mjs <url> --scroll 2000
|
|
178
178
|
```
|
package/skill/adapter/next.md
CHANGED
|
@@ -27,8 +27,7 @@ must survive one belongs in the layout.
|
|
|
27
27
|
|
|
28
28
|
## Scope
|
|
29
29
|
|
|
30
|
-
As in react.md
|
|
31
|
-
server and again on the client, and a counter would disagree between them.
|
|
30
|
+
As in react.md.
|
|
32
31
|
|
|
33
32
|
## Server rendering and hydration
|
|
34
33
|
|
package/skill/adapter/r3f.md
CHANGED
|
@@ -112,12 +112,15 @@ was created inside the body.
|
|
|
112
112
|
| The tween starts before the object exists | no early return on a null ref |
|
|
113
113
|
| Memory grows across route changes | objects created outside JSX, never disposed |
|
|
114
114
|
| Scroll-driven camera drifts out of sync | a scrub with an ease on a child, instead of `ease: "none"` |
|
|
115
|
+
| One long frame the first time a section scrolls in | shaders compiled on first draw — see [performance.md](../reference/performance.md#webgl-the-first-frame-of-each-material) |
|
|
116
|
+
| Scene stays black | WebGL context lost — see [performance.md](../reference/performance.md#webgl-losing-the-context-and-reading-it-back) |
|
|
115
117
|
|
|
116
118
|
## What the audit covers
|
|
117
119
|
|
|
118
120
|
The React rules from react.md, since these are `.tsx` files. From 3.2,
|
|
119
121
|
`useFrame` counts as a per-frame scope, so React state set inside it is reported
|
|
120
|
-
as `state-per-event
|
|
122
|
+
as `state-per-event`, and a Three vector or colour built in it as
|
|
123
|
+
`allocation-per-frame`.
|
|
121
124
|
|
|
122
125
|
Disposal, GPU cost and the render loop are not checkable — read for them, and
|
|
123
126
|
measure with [performance.md](../reference/performance.md).
|
package/skill/adapter/react.md
CHANGED
|
@@ -34,13 +34,18 @@ body re-runs on change) and `revertOnUpdate` (the previous run reverts first).
|
|
|
34
34
|
|
|
35
35
|
A tween created *outside* the body — in a handler, a promise, a callback — is
|
|
36
36
|
never reverted. Wrap that function in `contextSafe`, from the same hook, and the
|
|
37
|
-
context owns it
|
|
38
|
-
|
|
37
|
+
context owns it — with every tween it makes, killed ones too, until it reverts.
|
|
38
|
+
A frequent handler replays a timeline built in the body, or calls a `quickTo`. Inside `mm.add`, use its own: the callback's
|
|
39
39
|
second argument, `(context, contextSafe) =>`. `contextSafe` runs during render, so reading a
|
|
40
40
|
ref inside the callback trips React's "cannot access refs during render" rule —
|
|
41
41
|
build the timeline in the body, keep it on a ref, and expose a `useCallback`
|
|
42
42
|
that plays it.
|
|
43
43
|
|
|
44
|
+
**Without `@gsap/react`, write it by hand:** a `gsap.context(fn, root)` or
|
|
45
|
+
`gsap.matchMedia()` created in `useLayoutEffect`, its `revert` returned as the
|
|
46
|
+
cleanup. The audit reads that as it reads `useGSAP`. `ctx.add(null, fn)` is
|
|
47
|
+
`contextSafe`; `ctx.add(fn)` runs `fn` at once (verified against gsap 3.15).
|
|
48
|
+
|
|
44
49
|
## Where it is torn down
|
|
45
50
|
|
|
46
51
|
`useGSAP` reverts its context on unmount. Tweens, timelines, `ScrollTrigger`,
|
|
@@ -98,8 +103,7 @@ onMouseMove = (e) => moveX(e.clientX);
|
|
|
98
103
|
```
|
|
99
104
|
|
|
100
105
|
State is for *what exists* — is the modal open, which tab is active. GSAP owns
|
|
101
|
-
*how it moves*.
|
|
102
|
-
animated React.
|
|
106
|
+
*how it moves*.
|
|
103
107
|
|
|
104
108
|
## Page and route changes
|
|
105
109
|
|
|
@@ -118,7 +122,6 @@ App Router. After a navigation, `ScrollTrigger.refresh()` once it settles.
|
|
|
118
122
|
| Two components animate each other | missing `scope`, or a shared hardcoded id |
|
|
119
123
|
| Hydration mismatch | storage or a media query read during render |
|
|
120
124
|
| Janky pointer effect | React state in the handler |
|
|
121
|
-
| Hover sticks halfway | no `overwrite: "auto"` on the retriggered tween |
|
|
122
125
|
|
|
123
126
|
## What the audit covers
|
|
124
127
|
|
package/skill/adapter/three.md
CHANGED
|
@@ -115,6 +115,8 @@ is a live GPU context for a page that is gone.
|
|
|
115
115
|
| Memory grows on every visit | geometries, materials or textures never disposed |
|
|
116
116
|
| The camera snaps back every frame | `lookAt` overwriting a tweened rotation |
|
|
117
117
|
| The page is slow on phones before anything moves | Three in the initial bundle instead of imported dynamically |
|
|
118
|
+
| One long frame the first time a section scrolls in | shaders compiled on first draw — see [performance.md](../reference/performance.md#webgl-the-first-frame-of-each-material) |
|
|
119
|
+
| Scene stays black | WebGL context lost — see [performance.md](../reference/performance.md#webgl-losing-the-context-and-reading-it-back) |
|
|
118
120
|
|
|
119
121
|
## Cost and accessibility
|
|
120
122
|
|
|
@@ -1,101 +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)
|
|
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)
|