@mehshekari/gsap-motion 4.2.1 → 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.
@@ -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 |
@@ -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 |