@mehshekari/gsap-motion 3.1.1 → 3.3.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.
@@ -0,0 +1,110 @@
1
+ # Devices
2
+
3
+ A width breakpoint is not a device. A 1024px window can be a phone in landscape,
4
+ a tablet with a keyboard, or a desktop browser scaled to half the screen, and
5
+ "mobile" has stood in for all three for so long that most animation ships one
6
+ guess about which it is.
7
+
8
+ Read three axes instead. They are independent, and each answers a different
9
+ question.
10
+
11
+ | Axis | Question | Read from |
12
+ |---|---|---|
13
+ | Layout | How much room is there? | the viewport, in `matchMedia` |
14
+ | Input | Can the visitor hover, and how precise are they? | `(hover: hover)`, `(pointer: fine)` |
15
+ | Motion tier | How much motion should run, and can this device run it? | `prefers-reduced-motion`, then capability |
16
+
17
+ ```ts
18
+ const mm = gsap.matchMedia();
19
+
20
+ mm.add(
21
+ {
22
+ wide: "(min-width: 48em)",
23
+ hoverable: "(hover: hover) and (pointer: fine)",
24
+ motion: "(prefers-reduced-motion: no-preference)",
25
+ },
26
+ (context) => {
27
+ const { wide, hoverable, motion } = context.conditions;
28
+ // …one branch, three facts
29
+ },
30
+ );
31
+ ```
32
+
33
+ `matchMedia` re-runs the body when any condition changes and reverts what the
34
+ previous run created, so a visitor who rotates a phone, plugs in a mouse or
35
+ turns on reduced motion gets the right version without a reload.
36
+
37
+ ## The device changes the technique, never the story
38
+
39
+ A scene says something — this section arrived, that item is now first, the page
40
+ is loading. That does not change with the screen. What changes is how much
41
+ motion is spent saying it.
42
+
43
+ So each beat has three versions, and the beat decides which parts of itself are
44
+ device-sensitive:
45
+
46
+ - **Full** — the designed version.
47
+ - **Light** — the same meaning, less work: a shorter travel, no blur, fewer
48
+ elements moving, a stagger that runs as one group.
49
+ - **Static** — the end state, set. Still says *what* happened, with no travel.
50
+
51
+ Only branch the techniques that actually depend on the device. A fade is a fade
52
+ everywhere; a magnetic cursor is not. Writing three whole timelines where one
53
+ beat differs is how a page ends up with two motion languages and one of them
54
+ untested.
55
+
56
+ ## Start safe, then enhance
57
+
58
+ Begin from the version that works everywhere — the light one — and add to it
59
+ when an axis says there is room. The opposite order ships the expensive version
60
+ to the device least able to run it and then tries to claw it back.
61
+
62
+ If you measure capability at run time, **downgrade once**. A scene that keeps
63
+ sampling frame rate and flipping between versions spends its budget on the
64
+ flipping, and the visitor sees the page change its mind. Decide early, once, and
65
+ stay there.
66
+
67
+ Device memory and core count (`navigator.deviceMemory`, `hardwareConcurrency`)
68
+ are hints, not facts: they are absent on some browsers and rounded on others.
69
+ Use them to pick a tier, never to decide whether a thing works.
70
+
71
+ ## Where to read each axis
72
+
73
+ `prefers-reduced-motion`, `hover` and `pointer` are media queries, so
74
+ `gsap.matchMedia` is the one place to read them — inside the animation's own
75
+ context, where its cleanup already is.
76
+
77
+ The trap is reading them during render. A server has no viewport and no pointer,
78
+ so anything that branches on them before mount is a hydration mismatch waiting
79
+ to happen. Each adapter says where its own safe point is:
80
+
81
+ - **React and Next** — in the `useGSAP` body, never in render. See
82
+ [react.md](../adapter/react.md).
83
+ - **Vue and Nuxt** — in `onMounted`. See [vue.md](../adapter/vue.md).
84
+ - **Svelte** — in `$effect` or `onMount`. See [svelte.md](../adapter/svelte.md).
85
+ - **Astro** — in the component script, which is already browser-only. See
86
+ [astro.md](../adapter/astro.md).
87
+ - **Vanilla** — wherever you mount. See [vanilla.md](../adapter/vanilla.md).
88
+
89
+ ## What each axis is allowed to change
90
+
91
+ | Axis | Changes | Does not change |
92
+ |---|---|---|
93
+ | Layout | travel distance, stagger shape, which elements move, pinning | what the scene says |
94
+ | Input | hover effects, cursors, magnetic pulls, drag affordances | entrance and scroll behaviour |
95
+ | Motion tier | travel, loops, parallax, blur and shadow work | the end state the visitor is left in |
96
+
97
+ A hover effect with no input branch is the common failure: a tap on a touch
98
+ device fires `mouseenter`, nothing fires the leave, and the effect sticks. The
99
+ audit reports that as `ungated-hover`.
100
+
101
+ ## Reduced motion is a tier, not an off switch
102
+
103
+ The static version still communicates. It sets the end state, keeps the
104
+ hierarchy the motion was carrying — by order, weight or a very short fade — and
105
+ drops travel, loops and parallax. An empty branch is a page that looks broken to
106
+ the people who asked for less motion.
107
+
108
+ See [accessibility.md](accessibility.md) for what "reduced" means in practice,
109
+ and [performance.md](performance.md) for measuring a tier rather than guessing
110
+ it.
@@ -1,183 +1,183 @@
1
- # Interaction
2
-
3
- Pointer-driven motion. The engineering constraint is that these fire at 60–120Hz,
4
- so the wrong architecture costs a frame budget rather than a few milliseconds.
5
-
6
- Every interaction has three parts, and the third is the one people skip:
7
-
8
- ```text
9
- input → response → recovery
10
- ```
11
-
12
- The recovery is what makes it feel physical. A button that springs toward the
13
- cursor and snaps back on leave feels broken; the same button easing back over
14
- 0.6s feels alive.
15
-
16
- ## The rule: `quickTo`, not a tween per event
17
-
18
- ```ts
19
- // Wrong — allocates a tween per pointer event, and they fight each other
20
- onMouseMove = (e) => gsap.to(el, { x: e.clientX, duration: 0.5 });
21
-
22
- // Right — one tween, retargeted
23
- const moveX = gsap.quickTo(el, "x", { duration: 0.5, ease: "power3" });
24
- const moveY = gsap.quickTo(el, "y", { duration: 0.5, ease: "power3" });
25
- onMouseMove = (e) => { moveX(e.clientX); moveY(e.clientY); };
26
- ```
27
-
28
- Create the `quickTo` functions once, inside the `useGSAP` body. This is the
29
- single most important performance decision in pointer work.
30
-
31
- ## Magnetic element
32
-
33
- The element leans toward the cursor while it is near, and returns when it
34
- leaves.
35
-
36
- ```ts
37
- const xTo = gsap.quickTo(el, "x", { duration: 0.6, ease: "power3" });
38
- const yTo = gsap.quickTo(el, "y", { duration: 0.6, ease: "power3" });
39
-
40
- const onMove = (e: PointerEvent) => {
41
- const r = el.getBoundingClientRect();
42
- xTo((e.clientX - (r.left + r.width / 2)) * 0.35);
43
- yTo((e.clientY - (r.top + r.height / 2)) * 0.35);
44
- };
45
-
46
- const onLeave = () => { xTo(0); yTo(0); };
47
- ```
48
-
49
- - **Strength 0.2–0.4.** Above that the element outruns the cursor and feels
50
- unmoored.
51
- - **Measure in the handler, not once.** Scroll and layout change the rect. If
52
- that shows up as cost, cache it on enter and invalidate on scroll.
53
- - **The hit area should be larger than the element** — a padded wrapper listens,
54
- the inner element moves. That is what makes the pull feel like a field rather
55
- than a hover state.
56
-
57
- ## Cursor follower
58
-
59
- ```ts
60
- const xTo = gsap.quickTo(dot, "x", { duration: 0.35, ease: "power3" });
61
- const yTo = gsap.quickTo(dot, "y", { duration: 0.35, ease: "power3" });
62
-
63
- window.addEventListener("pointermove", (e) => { xTo(e.clientX); yTo(e.clientY); });
64
- ```
65
-
66
- - Two layers at different durations (0.1 and 0.5) give the trailing-ring look
67
- without a trail of elements.
68
- - `pointer-events: none` on the follower, always.
69
- - **Only on devices that have a cursor.** Gate on
70
- `(hover: hover) and (pointer: fine)` — a follower on a touch device is dead
71
- weight and sits in the corner.
72
- - Hiding the native cursor is a real accessibility cost. Do it only if the
73
- replacement is at least as legible over every surface, and never over text
74
- inputs.
75
-
76
- ## Observer
77
-
78
- One unified handler for wheel, touch, and pointer — the right tool when you want
79
- a gesture rather than a scroll position.
80
-
81
- ```ts
82
- import { Observer } from "gsap/Observer";
83
- gsap.registerPlugin(Observer);
84
-
85
- const obs = Observer.create({
86
- target: window,
87
- type: "wheel,touch,pointer",
88
- onUp: () => next(),
89
- onDown: () => prev(),
90
- tolerance: 10,
91
- preventDefault: true,
92
- });
93
-
94
- obs.kill(); // only needed outside a useGSAP body — see react-nextjs.md
95
- ```
96
-
97
- This is how full-screen section-snapping and swipe carousels are built without
98
- ScrollTrigger. `preventDefault: true` takes over native scrolling — a large
99
- commitment, and it must not trap a keyboard or screen-reader user.
100
-
101
- ## Draggable and Inertia
102
-
103
- ```ts
104
- import { Draggable } from "gsap/Draggable";
105
- import { InertiaPlugin } from "gsap/InertiaPlugin";
106
- gsap.registerPlugin(Draggable, InertiaPlugin);
107
-
108
- Draggable.create(el, {
109
- type: "x",
110
- bounds: container,
111
- inertia: true,
112
- snap: { x: (v) => Math.round(v / 320) * 320 },
113
- onDragEnd() { /* ... */ },
114
- });
115
- ```
116
-
117
- `inertia: true` gives the flick-and-settle that makes a drag feel native.
118
- `Draggable.create` returns instances — `.kill()` them on unmount.
119
-
120
- Anything draggable needs a keyboard path to the same outcome. A carousel that
121
- only responds to drag is unusable without a pointer.
122
-
123
- ## Hover with a timeline
124
-
125
- Build once, play and reverse. Better than creating tweens per event, and the
126
- return matches the arrival for free.
127
-
128
- ```ts
129
- const hover = gsap.timeline({ paused: true })
130
- .to(el, { scale: 1.04, duration: 0.3, ease: "power2.out" })
131
- .to(label, { y: -2, duration: 0.3 }, "<");
132
-
133
- onEnter = () => hover.play();
134
- onLeave = () => hover.reverse();
135
- ```
136
-
137
- In React, if the timeline is built inside `useGSAP` and only *played* from the
138
- handler, no `contextSafe` is needed — see [react-nextjs.md](react-nextjs.md).
139
-
140
- ## `overwrite: "auto"`
141
-
142
- The classic bug: enter, leave, enter fast, and the element sits halfway.
143
- Whatever retriggers needs `overwrite: "auto"` so the conflicting tween is killed
144
- rather than blended.
145
-
146
- ## Spotlight
147
-
148
- A pointer-tracked gradient. Drive CSS variables rather than re-rendering:
149
-
150
- ```ts
151
- const setX = gsap.quickSetter(card, "--spot-x", "px");
152
- const setY = gsap.quickSetter(card, "--spot-y", "px");
153
- ```
154
-
155
- ```css
156
- background: radial-gradient(
157
- 240px at var(--spot-x) var(--spot-y),
158
- var(--glow), transparent 70%
159
- );
160
- ```
161
-
162
- Cheap, and the styling stays in CSS where it can follow the theme.
163
-
164
- ## Accessibility
165
-
166
- - **Everything reachable by pointer must be reachable by keyboard.** Hover state
167
- and focus state should be the same visual.
168
- - Never destroy the focus ring with a transform that moves the element out from
169
- under it.
170
- - Gate hover effects on `(hover: hover)`. On touch, `:hover` sticks after a tap.
171
- - Under `prefers-reduced-motion: reduce`, keep the state change and drop the
172
- travel — see [accessibility.md](accessibility.md).
173
-
174
- ## Failures
175
-
176
- | Symptom | Cause |
177
- |---|---|
178
- | Janky on move | a tween per event, or React state in the handler |
179
- | Element sticks halfway | missing `overwrite: "auto"` |
180
- | Magnetic pull feels wrong | strength too high, or rect measured once |
181
- | Follower stuck in a corner | mounted on a touch device |
182
- | Hover sticks after tap | no `(hover: hover)` gate |
183
- | Effects survive navigation | `Observer`/`Draggable` not killed, listeners not removed |
1
+ # Interaction
2
+
3
+ Pointer-driven motion. The engineering constraint is that these fire at 60–120Hz,
4
+ so the wrong architecture costs a frame budget rather than a few milliseconds.
5
+
6
+ Every interaction has three parts, and the third is the one people skip:
7
+
8
+ ```text
9
+ input → response → recovery
10
+ ```
11
+
12
+ The recovery is what makes it feel physical. A button that springs toward the
13
+ cursor and snaps back on leave feels broken; the same button easing back over
14
+ 0.6s feels alive.
15
+
16
+ ## The rule: `quickTo`, not a tween per event
17
+
18
+ ```ts
19
+ // Wrong — allocates a tween per pointer event, and they fight each other
20
+ onMouseMove = (e) => gsap.to(el, { x: e.clientX, duration: 0.5 });
21
+
22
+ // Right — one tween, retargeted
23
+ const moveX = gsap.quickTo(el, "x", { duration: 0.5, ease: "power3" });
24
+ const moveY = gsap.quickTo(el, "y", { duration: 0.5, ease: "power3" });
25
+ onMouseMove = (e) => { moveX(e.clientX); moveY(e.clientY); };
26
+ ```
27
+
28
+ Create the `quickTo` functions once, inside the `useGSAP` body. This is the
29
+ single most important performance decision in pointer work.
30
+
31
+ ## Magnetic element
32
+
33
+ The element leans toward the cursor while it is near, and returns when it
34
+ leaves.
35
+
36
+ ```ts
37
+ const xTo = gsap.quickTo(el, "x", { duration: 0.6, ease: "power3" });
38
+ const yTo = gsap.quickTo(el, "y", { duration: 0.6, ease: "power3" });
39
+
40
+ const onMove = (e: PointerEvent) => {
41
+ const r = el.getBoundingClientRect();
42
+ xTo((e.clientX - (r.left + r.width / 2)) * 0.35);
43
+ yTo((e.clientY - (r.top + r.height / 2)) * 0.35);
44
+ };
45
+
46
+ const onLeave = () => { xTo(0); yTo(0); };
47
+ ```
48
+
49
+ - **Strength 0.2–0.4.** Above that the element outruns the cursor and feels
50
+ unmoored.
51
+ - **Measure in the handler, not once.** Scroll and layout change the rect. If
52
+ that shows up as cost, cache it on enter and invalidate on scroll.
53
+ - **The hit area should be larger than the element** — a padded wrapper listens,
54
+ the inner element moves. That is what makes the pull feel like a field rather
55
+ than a hover state.
56
+
57
+ ## Cursor follower
58
+
59
+ ```ts
60
+ const xTo = gsap.quickTo(dot, "x", { duration: 0.35, ease: "power3" });
61
+ const yTo = gsap.quickTo(dot, "y", { duration: 0.35, ease: "power3" });
62
+
63
+ window.addEventListener("pointermove", (e) => { xTo(e.clientX); yTo(e.clientY); });
64
+ ```
65
+
66
+ - Two layers at different durations (0.1 and 0.5) give the trailing-ring look
67
+ without a trail of elements.
68
+ - `pointer-events: none` on the follower, always.
69
+ - **Only on devices that have a cursor.** Gate on
70
+ `(hover: hover) and (pointer: fine)` — a follower on a touch device is dead
71
+ weight and sits in the corner.
72
+ - Hiding the native cursor is a real accessibility cost. Do it only if the
73
+ replacement is at least as legible over every surface, and never over text
74
+ inputs.
75
+
76
+ ## Observer
77
+
78
+ One unified handler for wheel, touch, and pointer — the right tool when you want
79
+ a gesture rather than a scroll position.
80
+
81
+ ```ts
82
+ import { Observer } from "gsap/Observer";
83
+ gsap.registerPlugin(Observer);
84
+
85
+ const obs = Observer.create({
86
+ target: window,
87
+ type: "wheel,touch,pointer",
88
+ onUp: () => next(),
89
+ onDown: () => prev(),
90
+ tolerance: 10,
91
+ preventDefault: true,
92
+ });
93
+
94
+ obs.kill(); // only needed outside a useGSAP body — see adapter/react.md
95
+ ```
96
+
97
+ This is how full-screen section-snapping and swipe carousels are built without
98
+ ScrollTrigger. `preventDefault: true` takes over native scrolling — a large
99
+ commitment, and it must not trap a keyboard or screen-reader user.
100
+
101
+ ## Draggable and Inertia
102
+
103
+ ```ts
104
+ import { Draggable } from "gsap/Draggable";
105
+ import { InertiaPlugin } from "gsap/InertiaPlugin";
106
+ gsap.registerPlugin(Draggable, InertiaPlugin);
107
+
108
+ Draggable.create(el, {
109
+ type: "x",
110
+ bounds: container,
111
+ inertia: true,
112
+ snap: { x: (v) => Math.round(v / 320) * 320 },
113
+ onDragEnd() { /* ... */ },
114
+ });
115
+ ```
116
+
117
+ `inertia: true` gives the flick-and-settle that makes a drag feel native.
118
+ `Draggable.create` returns instances — `.kill()` them on unmount.
119
+
120
+ Anything draggable needs a keyboard path to the same outcome. A carousel that
121
+ only responds to drag is unusable without a pointer.
122
+
123
+ ## Hover with a timeline
124
+
125
+ Build once, play and reverse. Better than creating tweens per event, and the
126
+ return matches the arrival for free.
127
+
128
+ ```ts
129
+ const hover = gsap.timeline({ paused: true })
130
+ .to(el, { scale: 1.04, duration: 0.3, ease: "power2.out" })
131
+ .to(label, { y: -2, duration: 0.3 }, "<");
132
+
133
+ onEnter = () => hover.play();
134
+ onLeave = () => hover.reverse();
135
+ ```
136
+
137
+ In React, if the timeline is built inside `useGSAP` and only *played* from the
138
+ handler, no `contextSafe` is needed — see [react.md](../adapter/react.md).
139
+
140
+ ## `overwrite: "auto"`
141
+
142
+ The classic bug: enter, leave, enter fast, and the element sits halfway.
143
+ Whatever retriggers needs `overwrite: "auto"` so the conflicting tween is killed
144
+ rather than blended.
145
+
146
+ ## Spotlight
147
+
148
+ A pointer-tracked gradient. Drive CSS variables rather than re-rendering:
149
+
150
+ ```ts
151
+ const setX = gsap.quickSetter(card, "--spot-x", "px");
152
+ const setY = gsap.quickSetter(card, "--spot-y", "px");
153
+ ```
154
+
155
+ ```css
156
+ background: radial-gradient(
157
+ 240px at var(--spot-x) var(--spot-y),
158
+ var(--glow), transparent 70%
159
+ );
160
+ ```
161
+
162
+ Cheap, and the styling stays in CSS where it can follow the theme.
163
+
164
+ ## Accessibility
165
+
166
+ - **Everything reachable by pointer must be reachable by keyboard.** Hover state
167
+ and focus state should be the same visual.
168
+ - Never destroy the focus ring with a transform that moves the element out from
169
+ under it.
170
+ - Gate hover effects on `(hover: hover)`. On touch, `:hover` sticks after a tap.
171
+ - Under `prefers-reduced-motion: reduce`, keep the state change and drop the
172
+ travel — see [accessibility.md](accessibility.md).
173
+
174
+ ## Failures
175
+
176
+ | Symptom | Cause |
177
+ |---|---|
178
+ | Janky on move | a tween per event, or React state in the handler |
179
+ | Element sticks halfway | missing `overwrite: "auto"` |
180
+ | Magnetic pull feels wrong | strength too high, or rect measured once |
181
+ | Follower stuck in a corner | mounted on a touch device |
182
+ | Hover sticks after tap | no `(hover: hover)` gate |
183
+ | Effects survive navigation | `Observer`/`Draggable` not killed, listeners not removed |
@@ -86,8 +86,8 @@ radiating, start-to-end reads as reading order. In RTL, "reading order" is
86
86
 
87
87
  ## Overlap
88
88
 
89
- Sequential beats look like a slideshow. Real choreography overlaps, and the
90
- overlap is where an animation stops feeling like a list of steps.
89
+ Sequential beats look like a slideshow. Overlap is where an animation stops
90
+ feeling like a list of steps.
91
91
 
92
92
  ```ts
93
93
  tl.to(a, { ... })
@@ -105,15 +105,18 @@ finishing.
105
105
  - **Weight** — a large or heavy-looking element should move slower and ease
106
106
  harder than a small one. Same duration on a hero image and a caption reads as
107
107
  a bug.
108
- - **Anticipation** — a small counter-move before the main one. Costs 80–120ms
109
- and makes a gesture feel intentional. Use on one element, not a group.
108
+ - **Anticipation** — a small counter-move before the main one: 80–120ms, on one
109
+ element, not a group.
110
110
  - **Follow-through** — elements should not all stop at once. A 0.05s offset on
111
- the last beat is usually the difference between mechanical and alive.
111
+ the last beat is the difference between mechanical and alive.
112
112
  - **Contrast** — fast against slow is what creates emphasis. If everything moves
113
113
  at 400ms, nothing is emphasised.
114
114
  - **Restraint** — the most premium-feeling motion is usually one or two things
115
115
  moving well, not six things moving at all.
116
116
 
117
+ Technique is chosen on a ladder — transition, tween, timeline, plugin, scroll
118
+ takeover — in [routing.md](routing.md).
119
+
117
120
  ## Reading a request
118
121
 
119
122
  | They say | They usually mean |
@@ -17,6 +17,18 @@ Before writing code, look for the project's animation rules, in this order:
17
17
  Read the whole file. Where it disagrees with this skill, the project wins. If a
18
18
  project rule looks wrong, say so — do not quietly follow the skill instead.
19
19
 
20
+ ## A motion budget
21
+
22
+ A page gets busy one reasonable addition at a time. A budget makes the cost
23
+ visible before the tenth one lands: how many groups may move at once, how many
24
+ loops may be visible, how many scroll-bound and pointer effects a viewport
25
+ carries, and which plugins a route may load.
26
+
27
+ Going over is allowed — with a reason stated in the answer, not by accident.
28
+ Before adding to a route, inventory what it already animates: that is step 2 of
29
+ the pipeline, and it is also how you find the animation this one should replace
30
+ rather than join.
31
+
20
32
  ## Deriving them when there is no file
21
33
 
22
34
  A few minutes here is cheaper than a page with two motion languages.
@@ -62,6 +74,10 @@ one is short, and each section is an instruction rather than an essay:
62
74
  | Stagger | `amount` (a total) or `each` (per item) |
63
75
  | Reduced motion | what the reduced branch shows |
64
76
 
77
+ ## Motion budget
78
+ <!-- Per viewport: groups moving at once, visible loops, scroll-bound effects,
79
+ pointer effects. Plugins per route. Going over is allowed, with a reason. -->
80
+
65
81
  ## Verify with
66
82
  <!-- The commands to run before calling it done. -->
67
83
 
@@ -0,0 +1,84 @@
1
+ # Refining
2
+
3
+ Writing the animation is the first draft. This is what turns it into something
4
+ worth shipping, and it is mostly checking rather than writing.
5
+
6
+ ```text
7
+ generate → audit → fix → re-run every check → tune → final
8
+ ```
9
+
10
+ The loop matters because the failures this skill exists to catch are silent: the
11
+ code compiles, the page looks right in a demo, and the leak shows up three
12
+ navigations later. Nothing downstream will tell you. So the check is part of
13
+ writing, not something done afterwards if there is time.
14
+
15
+ ## Audit first, and mean it
16
+
17
+ Run `audit-gsap` on the file you just wrote, before reading it yourself. It
18
+ costs a second, it is deterministic, and it finds the mechanical failures so
19
+ your attention goes to the ones it cannot see.
20
+
21
+ With the plugin installed, a hook does this automatically after every edit and
22
+ hands the findings back — see the Tools section of SKILL.md. Without it, run the
23
+ script.
24
+
25
+ A finding you believe is wrong is a bug in the script. Report it; do not work
26
+ around it with a waiver.
27
+
28
+ ## Fix in this order
29
+
30
+ The order is not taste. A fix further down the list can be undone by a fix
31
+ further up it, so doing them in this order avoids fixing the same thing twice.
32
+
33
+ 1. **Lifecycle and correctness** — does it clean up, does it run once, does it
34
+ target what you think it targets.
35
+ 2. **Accessibility** — the reduced-motion design, focus order, anything the
36
+ motion hides from a keyboard.
37
+ 3. **Performance** — layout and paint properties, per-frame allocation, work
38
+ that scales with the number of items.
39
+ 4. **Composition** — beats, hierarchy, what happens at the same time.
40
+ 5. **Rhythm** — durations, eases, overlap. Last, because everything above
41
+ changes it.
42
+
43
+ ## Verify a claim before you fix
44
+
45
+ When a fix rests on a claim about how something behaves — "`clearProps` runs
46
+ after the plugin's last write", "this element is the one that moves under
47
+ `fill`" — check it before writing the fix. A minimal reproduction, one variable
48
+ at a time, and read what actually happened in the DOM.
49
+
50
+ A fix built on a guess usually works by accident, and the comment above it is
51
+ then wrong for everyone who reads it next.
52
+
53
+ ## When to stop
54
+
55
+ Stop when every check is clean, or after **three rounds**, reporting what is
56
+ left.
57
+
58
+ Three is not a budget to spend. Most changes are right after one. Stopping on a
59
+ number matters for the case where a fix keeps moving the problem: the third
60
+ round is where you say what is still wrong and why, rather than trying a fourth
61
+ shape of the same idea.
62
+
63
+ **Leave what works alone.** The loop is for the animation you were asked about.
64
+ Retiming a neighbouring section because you noticed it is churn: it costs review
65
+ attention, it risks a regression nobody asked for, and it is exactly what the
66
+ `outcome-no-churn` eval measures.
67
+
68
+ **Depth scales with the request.** A hover retime does not need a composition
69
+ pass. A site intro does.
70
+
71
+ ## Report what you checked
72
+
73
+ The answer's **Verification** section is one line per check, with its evidence:
74
+
75
+ ```text
76
+ Verification
77
+ - audit-gsap: clean (3 warns waived: layout-property on the grid, with reasons)
78
+ - reduced motion: emulated, the static branch sets the end state
79
+ - StrictMode: mounted twice in dev, no doubled tweens
80
+ - not checked: frame cost on a real phone — no device to hand
81
+ ```
82
+
83
+ Say what you did not check, too. "Not checked" is information; silence reads as
84
+ "checked and fine", and that is the one thing it must never mean.