@mehshekari/gsap-motion 4.3.0 → 4.4.2
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 +7 -0
- 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 +5 -1
- 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 +54 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +4 -0
- package/skill/scripts/capture-motion.mjs +120 -10
- package/skill/scripts/lib/ast.mjs +58 -9
- package/skill/scripts/lib/rules.mjs +134 -5
- package/skill/scripts/review-motion.mjs +167 -8
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
package/skill/preset/cursor.md
CHANGED
|
@@ -1,184 +1,184 @@
|
|
|
1
|
-
# Cursor
|
|
2
|
-
|
|
3
|
-
A custom pointer. High reward and high risk: done well it makes a site feel
|
|
4
|
-
authored, done badly it makes it unusable.
|
|
5
|
-
|
|
6
|
-
**Decide first whether to hide the native cursor.** Keeping it and adding a
|
|
7
|
-
follower is almost always the better trade — you get the character without
|
|
8
|
-
taking away the visitor's most reliable affordance.
|
|
9
|
-
|
|
10
|
-
## Two layers, two speeds
|
|
11
|
-
|
|
12
|
-
One element tracking instantly, one lagging. That difference is the whole
|
|
13
|
-
effect; a single follower reads as a laggy cursor.
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
"use client";
|
|
17
|
-
|
|
18
|
-
import { useGSAP } from "@gsap/react";
|
|
19
|
-
import gsap from "gsap";
|
|
20
|
-
import { useRef } from "react";
|
|
21
|
-
|
|
22
|
-
gsap.registerPlugin(useGSAP);
|
|
23
|
-
|
|
24
|
-
export function useCursor() {
|
|
25
|
-
const dotRef = useRef<HTMLDivElement>(null);
|
|
26
|
-
const ringRef = useRef<HTMLDivElement>(null);
|
|
27
|
-
|
|
28
|
-
useGSAP(() => {
|
|
29
|
-
const dot = dotRef.current;
|
|
30
|
-
const ring = ringRef.current;
|
|
31
|
-
if (!dot || !ring) return;
|
|
32
|
-
|
|
33
|
-
const mm = gsap.matchMedia();
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A follower on a touch device is dead weight parked in a corner, and one
|
|
37
|
-
* that chases a pointer for someone who asked for reduced motion is the
|
|
38
|
-
* kind of unrequested movement the preference exists to stop.
|
|
39
|
-
*/
|
|
40
|
-
mm.add(
|
|
41
|
-
"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
|
|
42
|
-
() => {
|
|
43
|
-
const to = (el: HTMLElement, axis: "x" | "y", duration: number) =>
|
|
44
|
-
gsap.quickTo(el, axis, { duration, ease: "power3" });
|
|
45
|
-
|
|
46
|
-
const dotX = to(dot, "x", 0.12);
|
|
47
|
-
const dotY = to(dot, "y", 0.12);
|
|
48
|
-
const ringX = to(ring, "x", 0.5);
|
|
49
|
-
const ringY = to(ring, "y", 0.5);
|
|
50
|
-
|
|
51
|
-
gsap.set([dot, ring], { xPercent: -50, yPercent: -50, autoAlpha: 0 });
|
|
52
|
-
|
|
53
|
-
let seen = false;
|
|
54
|
-
const onMove = (event: PointerEvent) => {
|
|
55
|
-
if (!seen) {
|
|
56
|
-
seen = true;
|
|
57
|
-
/** Revealed on first move, so it never paints in the corner. */
|
|
58
|
-
gsap.to([dot, ring], { autoAlpha: 1, duration: 0.3 });
|
|
59
|
-
}
|
|
60
|
-
dotX(event.clientX);
|
|
61
|
-
dotY(event.clientY);
|
|
62
|
-
ringX(event.clientX);
|
|
63
|
-
ringY(event.clientY);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/** Hidden when the pointer leaves the document entirely. */
|
|
67
|
-
const onOut = () => gsap.to([dot, ring], { autoAlpha: 0, duration: 0.2 });
|
|
68
|
-
|
|
69
|
-
window.addEventListener("pointermove", onMove);
|
|
70
|
-
document.addEventListener("pointerleave", onOut);
|
|
71
|
-
|
|
72
|
-
return () => {
|
|
73
|
-
window.removeEventListener("pointermove", onMove);
|
|
74
|
-
document.removeEventListener("pointerleave", onOut);
|
|
75
|
-
};
|
|
76
|
-
},
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
/* The context reverts `mm` itself; the listeners above are ours. */
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
return { dotRef, ringRef };
|
|
83
|
-
}
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`xPercent: -50, yPercent: -50` centres each layer on the pointer without knowing
|
|
87
|
-
its size.
|
|
88
|
-
|
|
89
|
-
## Markup
|
|
90
|
-
|
|
91
|
-
```tsx
|
|
92
|
-
<div ref={ringRef} aria-hidden="true"
|
|
93
|
-
className="pointer-events-none fixed start-0 top-0 z-200 size-9 rounded-full border" />
|
|
94
|
-
<div ref={dotRef} aria-hidden="true"
|
|
95
|
-
className="pointer-events-none fixed start-0 top-0 z-200 size-1.5 rounded-full" />
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
`pointer-events: none` and `aria-hidden` on both, always. `fixed` positioning so
|
|
99
|
-
the coordinates are viewport coordinates — no scroll maths.
|
|
100
|
-
|
|
101
|
-
## Reacting to what is under it
|
|
102
|
-
|
|
103
|
-
The character comes from the follower changing over different targets. Drive it
|
|
104
|
-
from a data attribute rather than a list of selectors:
|
|
105
|
-
|
|
106
|
-
```tsx
|
|
107
|
-
<a data-cursor="expand">…</a>
|
|
108
|
-
<video data-cursor="play">…</video>
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
```ts
|
|
112
|
-
const onOver = (event: PointerEvent) => {
|
|
113
|
-
const hit = (event.target as Element).closest("[data-cursor]");
|
|
114
|
-
const mode = hit?.getAttribute("data-cursor");
|
|
115
|
-
|
|
116
|
-
gsap.to(ring, {
|
|
117
|
-
scale: mode === "expand" ? 2.4 : 1,
|
|
118
|
-
borderWidth: mode === "expand" ? 1 : 2,
|
|
119
|
-
duration: 0.4,
|
|
120
|
-
ease: "power3.out",
|
|
121
|
-
overwrite: "auto",
|
|
122
|
-
});
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
document.addEventListener("pointerover", onOver);
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
`overwrite: "auto"` matters here — moving quickly across several targets
|
|
129
|
-
otherwise leaves the ring blended between two states.
|
|
130
|
-
|
|
131
|
-
## Capture
|
|
132
|
-
|
|
133
|
-
The ring sticks to an element's centre while the pointer is over it. This is the
|
|
134
|
-
effect that makes navigation feel magnetic without moving the link itself:
|
|
135
|
-
|
|
136
|
-
```ts
|
|
137
|
-
const rect = hit.getBoundingClientRect();
|
|
138
|
-
ringX(rect.left + rect.width / 2);
|
|
139
|
-
ringY(rect.top + rect.height / 2);
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
Combine with [magnetic.md](magnetic.md) carefully — both moving at once reads as
|
|
143
|
-
instability. Pick one.
|
|
144
|
-
|
|
145
|
-
## Hiding the native cursor
|
|
146
|
-
|
|
147
|
-
If you must:
|
|
148
|
-
|
|
149
|
-
```css
|
|
150
|
-
@media (hover: hover) and (pointer: fine) {
|
|
151
|
-
body { cursor: none; }
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
Then the replacement must be at least as legible over every surface — dark,
|
|
156
|
-
light, image, video — and must never apply over text inputs or textareas, where
|
|
157
|
-
the I-beam carries real information:
|
|
158
|
-
|
|
159
|
-
```css
|
|
160
|
-
input, textarea, [contenteditable] { cursor: auto; }
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Restore the cursor on any surface where your follower might not render: a
|
|
164
|
-
`<canvas>`, an `<iframe>`, a native `<select>` popup.
|
|
165
|
-
|
|
166
|
-
## Accessibility
|
|
167
|
-
|
|
168
|
-
The follower is decoration. Nothing about it may carry information — a "click
|
|
169
|
-
me" that only exists as a cursor state is invisible to a keyboard, to touch, and
|
|
170
|
-
to a screen reader.
|
|
171
|
-
|
|
172
|
-
The `(hover: hover)` and reduced-motion gates in the `matchMedia` query are not
|
|
173
|
-
optional.
|
|
174
|
-
|
|
175
|
-
## Failures
|
|
176
|
-
|
|
177
|
-
| Symptom | Cause |
|
|
178
|
-
|---|---|
|
|
179
|
-
| Sits in the corner on mobile | missing `(hover: hover)` gate |
|
|
180
|
-
| Flashes at 0,0 on load | not hidden until the first move |
|
|
181
|
-
| Blocks clicks | missing `pointer-events: none` |
|
|
182
|
-
| Blends between hover states | missing `overwrite: "auto"` |
|
|
183
|
-
| Offset by the scroll | `absolute` instead of `fixed` |
|
|
184
|
-
| Survives navigation | listeners not removed |
|
|
1
|
+
# Cursor
|
|
2
|
+
|
|
3
|
+
A custom pointer. High reward and high risk: done well it makes a site feel
|
|
4
|
+
authored, done badly it makes it unusable.
|
|
5
|
+
|
|
6
|
+
**Decide first whether to hide the native cursor.** Keeping it and adding a
|
|
7
|
+
follower is almost always the better trade — you get the character without
|
|
8
|
+
taking away the visitor's most reliable affordance.
|
|
9
|
+
|
|
10
|
+
## Two layers, two speeds
|
|
11
|
+
|
|
12
|
+
One element tracking instantly, one lagging. That difference is the whole
|
|
13
|
+
effect; a single follower reads as a laggy cursor.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
"use client";
|
|
17
|
+
|
|
18
|
+
import { useGSAP } from "@gsap/react";
|
|
19
|
+
import gsap from "gsap";
|
|
20
|
+
import { useRef } from "react";
|
|
21
|
+
|
|
22
|
+
gsap.registerPlugin(useGSAP);
|
|
23
|
+
|
|
24
|
+
export function useCursor() {
|
|
25
|
+
const dotRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
const ringRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
|
|
28
|
+
useGSAP(() => {
|
|
29
|
+
const dot = dotRef.current;
|
|
30
|
+
const ring = ringRef.current;
|
|
31
|
+
if (!dot || !ring) return;
|
|
32
|
+
|
|
33
|
+
const mm = gsap.matchMedia();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A follower on a touch device is dead weight parked in a corner, and one
|
|
37
|
+
* that chases a pointer for someone who asked for reduced motion is the
|
|
38
|
+
* kind of unrequested movement the preference exists to stop.
|
|
39
|
+
*/
|
|
40
|
+
mm.add(
|
|
41
|
+
"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
|
|
42
|
+
() => {
|
|
43
|
+
const to = (el: HTMLElement, axis: "x" | "y", duration: number) =>
|
|
44
|
+
gsap.quickTo(el, axis, { duration, ease: "power3" });
|
|
45
|
+
|
|
46
|
+
const dotX = to(dot, "x", 0.12);
|
|
47
|
+
const dotY = to(dot, "y", 0.12);
|
|
48
|
+
const ringX = to(ring, "x", 0.5);
|
|
49
|
+
const ringY = to(ring, "y", 0.5);
|
|
50
|
+
|
|
51
|
+
gsap.set([dot, ring], { xPercent: -50, yPercent: -50, autoAlpha: 0 });
|
|
52
|
+
|
|
53
|
+
let seen = false;
|
|
54
|
+
const onMove = (event: PointerEvent) => {
|
|
55
|
+
if (!seen) {
|
|
56
|
+
seen = true;
|
|
57
|
+
/** Revealed on first move, so it never paints in the corner. */
|
|
58
|
+
gsap.to([dot, ring], { autoAlpha: 1, duration: 0.3 });
|
|
59
|
+
}
|
|
60
|
+
dotX(event.clientX);
|
|
61
|
+
dotY(event.clientY);
|
|
62
|
+
ringX(event.clientX);
|
|
63
|
+
ringY(event.clientY);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Hidden when the pointer leaves the document entirely. */
|
|
67
|
+
const onOut = () => gsap.to([dot, ring], { autoAlpha: 0, duration: 0.2 });
|
|
68
|
+
|
|
69
|
+
window.addEventListener("pointermove", onMove);
|
|
70
|
+
document.addEventListener("pointerleave", onOut);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener("pointermove", onMove);
|
|
74
|
+
document.removeEventListener("pointerleave", onOut);
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/* The context reverts `mm` itself; the listeners above are ours. */
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return { dotRef, ringRef };
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`xPercent: -50, yPercent: -50` centres each layer on the pointer without knowing
|
|
87
|
+
its size.
|
|
88
|
+
|
|
89
|
+
## Markup
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
<div ref={ringRef} aria-hidden="true"
|
|
93
|
+
className="pointer-events-none fixed start-0 top-0 z-200 size-9 rounded-full border" />
|
|
94
|
+
<div ref={dotRef} aria-hidden="true"
|
|
95
|
+
className="pointer-events-none fixed start-0 top-0 z-200 size-1.5 rounded-full" />
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`pointer-events: none` and `aria-hidden` on both, always. `fixed` positioning so
|
|
99
|
+
the coordinates are viewport coordinates — no scroll maths.
|
|
100
|
+
|
|
101
|
+
## Reacting to what is under it
|
|
102
|
+
|
|
103
|
+
The character comes from the follower changing over different targets. Drive it
|
|
104
|
+
from a data attribute rather than a list of selectors:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
<a data-cursor="expand">…</a>
|
|
108
|
+
<video data-cursor="play">…</video>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const onOver = (event: PointerEvent) => {
|
|
113
|
+
const hit = (event.target as Element).closest("[data-cursor]");
|
|
114
|
+
const mode = hit?.getAttribute("data-cursor");
|
|
115
|
+
|
|
116
|
+
gsap.to(ring, {
|
|
117
|
+
scale: mode === "expand" ? 2.4 : 1,
|
|
118
|
+
borderWidth: mode === "expand" ? 1 : 2,
|
|
119
|
+
duration: 0.4,
|
|
120
|
+
ease: "power3.out",
|
|
121
|
+
overwrite: "auto",
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
document.addEventListener("pointerover", onOver);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`overwrite: "auto"` matters here — moving quickly across several targets
|
|
129
|
+
otherwise leaves the ring blended between two states.
|
|
130
|
+
|
|
131
|
+
## Capture
|
|
132
|
+
|
|
133
|
+
The ring sticks to an element's centre while the pointer is over it. This is the
|
|
134
|
+
effect that makes navigation feel magnetic without moving the link itself:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const rect = hit.getBoundingClientRect();
|
|
138
|
+
ringX(rect.left + rect.width / 2);
|
|
139
|
+
ringY(rect.top + rect.height / 2);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Combine with [magnetic.md](magnetic.md) carefully — both moving at once reads as
|
|
143
|
+
instability. Pick one.
|
|
144
|
+
|
|
145
|
+
## Hiding the native cursor
|
|
146
|
+
|
|
147
|
+
If you must:
|
|
148
|
+
|
|
149
|
+
```css
|
|
150
|
+
@media (hover: hover) and (pointer: fine) {
|
|
151
|
+
body { cursor: none; }
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Then the replacement must be at least as legible over every surface — dark,
|
|
156
|
+
light, image, video — and must never apply over text inputs or textareas, where
|
|
157
|
+
the I-beam carries real information:
|
|
158
|
+
|
|
159
|
+
```css
|
|
160
|
+
input, textarea, [contenteditable] { cursor: auto; }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Restore the cursor on any surface where your follower might not render: a
|
|
164
|
+
`<canvas>`, an `<iframe>`, a native `<select>` popup.
|
|
165
|
+
|
|
166
|
+
## Accessibility
|
|
167
|
+
|
|
168
|
+
The follower is decoration. Nothing about it may carry information — a "click
|
|
169
|
+
me" that only exists as a cursor state is invisible to a keyboard, to touch, and
|
|
170
|
+
to a screen reader.
|
|
171
|
+
|
|
172
|
+
The `(hover: hover)` and reduced-motion gates in the `matchMedia` query are not
|
|
173
|
+
optional.
|
|
174
|
+
|
|
175
|
+
## Failures
|
|
176
|
+
|
|
177
|
+
| Symptom | Cause |
|
|
178
|
+
|---|---|
|
|
179
|
+
| Sits in the corner on mobile | missing `(hover: hover)` gate |
|
|
180
|
+
| Flashes at 0,0 on load | not hidden until the first move |
|
|
181
|
+
| Blocks clicks | missing `pointer-events: none` |
|
|
182
|
+
| Blends between hover states | missing `overwrite: "auto"` |
|
|
183
|
+
| Offset by the scroll | `absolute` instead of `fixed` |
|
|
184
|
+
| Survives navigation | listeners not removed |
|
package/skill/preset/magnetic.md
CHANGED
|
@@ -1,155 +1,155 @@
|
|
|
1
|
-
# Magnetic
|
|
2
|
-
|
|
3
|
-
An element that leans toward the cursor while it is near and eases back when it
|
|
4
|
-
leaves. Says "this is interactive" more convincingly than any hover state,
|
|
5
|
-
because the response starts before the pointer arrives.
|
|
6
|
-
|
|
7
|
-
Mechanics and the `quickTo` rule are in
|
|
8
|
-
[interaction.md](../reference/interaction.md); this is the tuned recipe.
|
|
9
|
-
|
|
10
|
-
## The hook
|
|
11
|
-
|
|
12
|
-
```ts
|
|
13
|
-
"use client";
|
|
14
|
-
|
|
15
|
-
import { useGSAP } from "@gsap/react";
|
|
16
|
-
import gsap from "gsap";
|
|
17
|
-
import { useRef } from "react";
|
|
18
|
-
|
|
19
|
-
gsap.registerPlugin(useGSAP);
|
|
20
|
-
|
|
21
|
-
/** Fraction of the cursor's offset the element travels. Above ~0.4 it outruns
|
|
22
|
-
* the pointer and stops feeling attached. */
|
|
23
|
-
const PULL = 0.32;
|
|
24
|
-
/** How much further the inner label travels, so the button has some depth. */
|
|
25
|
-
const LABEL_PULL = 0.5;
|
|
26
|
-
|
|
27
|
-
export function useMagnetic() {
|
|
28
|
-
const zoneRef = useRef<HTMLDivElement>(null);
|
|
29
|
-
const targetRef = useRef<HTMLElement>(null);
|
|
30
|
-
const labelRef = useRef<HTMLElement>(null);
|
|
31
|
-
|
|
32
|
-
useGSAP(
|
|
33
|
-
() => {
|
|
34
|
-
const zone = zoneRef.current;
|
|
35
|
-
const target = targetRef.current;
|
|
36
|
-
if (!zone || !target) return;
|
|
37
|
-
|
|
38
|
-
const mm = gsap.matchMedia();
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Gated on a real cursor. On touch there is no hover to leave, so the
|
|
42
|
-
* element would stick wherever the last tap put it. Reduced motion keeps
|
|
43
|
-
* the affordance and drops the travel.
|
|
44
|
-
*/
|
|
45
|
-
mm.add(
|
|
46
|
-
"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
|
|
47
|
-
() => {
|
|
48
|
-
const to = (el: HTMLElement, axis: "x" | "y") =>
|
|
49
|
-
gsap.quickTo(el, axis, { duration: 0.6, ease: "power3" });
|
|
50
|
-
|
|
51
|
-
const move = { x: to(target, "x"), y: to(target, "y") };
|
|
52
|
-
const label = labelRef.current
|
|
53
|
-
? { x: to(labelRef.current, "x"), y: to(labelRef.current, "y") }
|
|
54
|
-
: null;
|
|
55
|
-
|
|
56
|
-
const onMove = (event: PointerEvent) => {
|
|
57
|
-
/** Measured per move: scroll and layout both shift the rect. */
|
|
58
|
-
const rect = target.getBoundingClientRect();
|
|
59
|
-
const dx = event.clientX - (rect.left + rect.width / 2);
|
|
60
|
-
const dy = event.clientY - (rect.top + rect.height / 2);
|
|
61
|
-
|
|
62
|
-
move.x(dx * PULL);
|
|
63
|
-
move.y(dy * PULL);
|
|
64
|
-
label?.x(dx * PULL * LABEL_PULL);
|
|
65
|
-
label?.y(dy * PULL * LABEL_PULL);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const onLeave = () => {
|
|
69
|
-
move.x(0);
|
|
70
|
-
move.y(0);
|
|
71
|
-
label?.x(0);
|
|
72
|
-
label?.y(0);
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
zone.addEventListener("pointermove", onMove);
|
|
76
|
-
zone.addEventListener("pointerleave", onLeave);
|
|
77
|
-
|
|
78
|
-
return () => {
|
|
79
|
-
zone.removeEventListener("pointermove", onMove);
|
|
80
|
-
zone.removeEventListener("pointerleave", onLeave);
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
/* The context reverts `mm` itself; the listeners are ours. */
|
|
86
|
-
},
|
|
87
|
-
{ scope: zoneRef },
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
return { zoneRef, targetRef, labelRef };
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## The markup
|
|
95
|
-
|
|
96
|
-
The listening zone is larger than the element. That is what turns a hover state
|
|
97
|
-
into a field — the pull begins before the cursor arrives.
|
|
98
|
-
|
|
99
|
-
```tsx
|
|
100
|
-
<div ref={zoneRef} className="inline-flex p-6">
|
|
101
|
-
<button ref={targetRef} className="rounded-full px-6 py-3">
|
|
102
|
-
<span ref={labelRef} className="inline-block">Get in touch</span>
|
|
103
|
-
</button>
|
|
104
|
-
</div>
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
The padding is the field radius. 24–40px is usually right; larger and the
|
|
108
|
-
element starts moving for pointers that were never coming.
|
|
109
|
-
|
|
110
|
-
## Values
|
|
111
|
-
|
|
112
|
-
| Value | Range | Note |
|
|
113
|
-
|---|---|---|
|
|
114
|
-
| `PULL` | 0.2–0.4 | above 0.4 it detaches from the cursor |
|
|
115
|
-
| duration | 0.4–0.8 | shorter feels twitchy, longer feels like lag |
|
|
116
|
-
| ease | `power3` | `power2` is softer; avoid `elastic` here |
|
|
117
|
-
| zone padding | 24–40px | the field radius |
|
|
118
|
-
| `LABEL_PULL` | 0.4–0.6 | the parallax that gives it depth |
|
|
119
|
-
|
|
120
|
-
## Variations
|
|
121
|
-
|
|
122
|
-
**Scale on enter** — one paused timeline, played and reversed, so the return
|
|
123
|
-
matches without a second definition:
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
const hover = gsap.timeline({ paused: true })
|
|
127
|
-
.to(target, { scale: 1.05, duration: 0.3, ease: "power2.out" });
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
**Rotation instead of translation** — a subtle `rotation: dx * 0.02` reads as
|
|
131
|
-
the element tilting to face the cursor. Good for cards, wrong for buttons.
|
|
132
|
-
|
|
133
|
-
**Magnetic cursor, static button** — invert it: the follower is captured by the
|
|
134
|
-
button's centre on enter. See [cursor.md](cursor.md).
|
|
135
|
-
|
|
136
|
-
## Accessibility
|
|
137
|
-
|
|
138
|
-
The pull is decoration and a keyboard never sees it, so **the focus state must
|
|
139
|
-
carry the whole affordance on its own** — the same visual change the hover
|
|
140
|
-
implies, not a fallback. And the transform must not move the element out from
|
|
141
|
-
under its focus ring.
|
|
142
|
-
|
|
143
|
-
Both gates in the `matchMedia` query above are load-bearing: without
|
|
144
|
-
`(hover: hover)` the element sticks after a tap on touch; without the
|
|
145
|
-
reduced-motion clause it moves for someone who asked it not to.
|
|
146
|
-
|
|
147
|
-
## Failures
|
|
148
|
-
|
|
149
|
-
| Symptom | Cause |
|
|
150
|
-
|---|---|
|
|
151
|
-
| Janky | a tween per event instead of `quickTo` |
|
|
152
|
-
| Drifts and never returns | `pointerleave` on the element, not the zone |
|
|
153
|
-
| Wrong offset after scrolling | rect cached instead of measured |
|
|
154
|
-
| Stuck after a tap on mobile | missing `(hover: hover)` gate |
|
|
155
|
-
| Feels detached | `PULL` too high |
|
|
1
|
+
# Magnetic
|
|
2
|
+
|
|
3
|
+
An element that leans toward the cursor while it is near and eases back when it
|
|
4
|
+
leaves. Says "this is interactive" more convincingly than any hover state,
|
|
5
|
+
because the response starts before the pointer arrives.
|
|
6
|
+
|
|
7
|
+
Mechanics and the `quickTo` rule are in
|
|
8
|
+
[interaction.md](../reference/interaction.md); this is the tuned recipe.
|
|
9
|
+
|
|
10
|
+
## The hook
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
"use client";
|
|
14
|
+
|
|
15
|
+
import { useGSAP } from "@gsap/react";
|
|
16
|
+
import gsap from "gsap";
|
|
17
|
+
import { useRef } from "react";
|
|
18
|
+
|
|
19
|
+
gsap.registerPlugin(useGSAP);
|
|
20
|
+
|
|
21
|
+
/** Fraction of the cursor's offset the element travels. Above ~0.4 it outruns
|
|
22
|
+
* the pointer and stops feeling attached. */
|
|
23
|
+
const PULL = 0.32;
|
|
24
|
+
/** How much further the inner label travels, so the button has some depth. */
|
|
25
|
+
const LABEL_PULL = 0.5;
|
|
26
|
+
|
|
27
|
+
export function useMagnetic() {
|
|
28
|
+
const zoneRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const targetRef = useRef<HTMLElement>(null);
|
|
30
|
+
const labelRef = useRef<HTMLElement>(null);
|
|
31
|
+
|
|
32
|
+
useGSAP(
|
|
33
|
+
() => {
|
|
34
|
+
const zone = zoneRef.current;
|
|
35
|
+
const target = targetRef.current;
|
|
36
|
+
if (!zone || !target) return;
|
|
37
|
+
|
|
38
|
+
const mm = gsap.matchMedia();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gated on a real cursor. On touch there is no hover to leave, so the
|
|
42
|
+
* element would stick wherever the last tap put it. Reduced motion keeps
|
|
43
|
+
* the affordance and drops the travel.
|
|
44
|
+
*/
|
|
45
|
+
mm.add(
|
|
46
|
+
"(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
|
|
47
|
+
() => {
|
|
48
|
+
const to = (el: HTMLElement, axis: "x" | "y") =>
|
|
49
|
+
gsap.quickTo(el, axis, { duration: 0.6, ease: "power3" });
|
|
50
|
+
|
|
51
|
+
const move = { x: to(target, "x"), y: to(target, "y") };
|
|
52
|
+
const label = labelRef.current
|
|
53
|
+
? { x: to(labelRef.current, "x"), y: to(labelRef.current, "y") }
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
const onMove = (event: PointerEvent) => {
|
|
57
|
+
/** Measured per move: scroll and layout both shift the rect. */
|
|
58
|
+
const rect = target.getBoundingClientRect();
|
|
59
|
+
const dx = event.clientX - (rect.left + rect.width / 2);
|
|
60
|
+
const dy = event.clientY - (rect.top + rect.height / 2);
|
|
61
|
+
|
|
62
|
+
move.x(dx * PULL);
|
|
63
|
+
move.y(dy * PULL);
|
|
64
|
+
label?.x(dx * PULL * LABEL_PULL);
|
|
65
|
+
label?.y(dy * PULL * LABEL_PULL);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const onLeave = () => {
|
|
69
|
+
move.x(0);
|
|
70
|
+
move.y(0);
|
|
71
|
+
label?.x(0);
|
|
72
|
+
label?.y(0);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
zone.addEventListener("pointermove", onMove);
|
|
76
|
+
zone.addEventListener("pointerleave", onLeave);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
zone.removeEventListener("pointermove", onMove);
|
|
80
|
+
zone.removeEventListener("pointerleave", onLeave);
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/* The context reverts `mm` itself; the listeners are ours. */
|
|
86
|
+
},
|
|
87
|
+
{ scope: zoneRef },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { zoneRef, targetRef, labelRef };
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## The markup
|
|
95
|
+
|
|
96
|
+
The listening zone is larger than the element. That is what turns a hover state
|
|
97
|
+
into a field — the pull begins before the cursor arrives.
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
<div ref={zoneRef} className="inline-flex p-6">
|
|
101
|
+
<button ref={targetRef} className="rounded-full px-6 py-3">
|
|
102
|
+
<span ref={labelRef} className="inline-block">Get in touch</span>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The padding is the field radius. 24–40px is usually right; larger and the
|
|
108
|
+
element starts moving for pointers that were never coming.
|
|
109
|
+
|
|
110
|
+
## Values
|
|
111
|
+
|
|
112
|
+
| Value | Range | Note |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `PULL` | 0.2–0.4 | above 0.4 it detaches from the cursor |
|
|
115
|
+
| duration | 0.4–0.8 | shorter feels twitchy, longer feels like lag |
|
|
116
|
+
| ease | `power3` | `power2` is softer; avoid `elastic` here |
|
|
117
|
+
| zone padding | 24–40px | the field radius |
|
|
118
|
+
| `LABEL_PULL` | 0.4–0.6 | the parallax that gives it depth |
|
|
119
|
+
|
|
120
|
+
## Variations
|
|
121
|
+
|
|
122
|
+
**Scale on enter** — one paused timeline, played and reversed, so the return
|
|
123
|
+
matches without a second definition:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const hover = gsap.timeline({ paused: true })
|
|
127
|
+
.to(target, { scale: 1.05, duration: 0.3, ease: "power2.out" });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Rotation instead of translation** — a subtle `rotation: dx * 0.02` reads as
|
|
131
|
+
the element tilting to face the cursor. Good for cards, wrong for buttons.
|
|
132
|
+
|
|
133
|
+
**Magnetic cursor, static button** — invert it: the follower is captured by the
|
|
134
|
+
button's centre on enter. See [cursor.md](cursor.md).
|
|
135
|
+
|
|
136
|
+
## Accessibility
|
|
137
|
+
|
|
138
|
+
The pull is decoration and a keyboard never sees it, so **the focus state must
|
|
139
|
+
carry the whole affordance on its own** — the same visual change the hover
|
|
140
|
+
implies, not a fallback. And the transform must not move the element out from
|
|
141
|
+
under its focus ring.
|
|
142
|
+
|
|
143
|
+
Both gates in the `matchMedia` query above are load-bearing: without
|
|
144
|
+
`(hover: hover)` the element sticks after a tap on touch; without the
|
|
145
|
+
reduced-motion clause it moves for someone who asked it not to.
|
|
146
|
+
|
|
147
|
+
## Failures
|
|
148
|
+
|
|
149
|
+
| Symptom | Cause |
|
|
150
|
+
|---|---|
|
|
151
|
+
| Janky | a tween per event instead of `quickTo` |
|
|
152
|
+
| Drifts and never returns | `pointerleave` on the element, not the zone |
|
|
153
|
+
| Wrong offset after scrolling | rect cached instead of measured |
|
|
154
|
+
| Stuck after a tap on mobile | missing `(hover: hover)` gate |
|
|
155
|
+
| Feels detached | `PULL` too high |
|