@mehshekari/gsap-motion 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +24 -0
  3. package/README.md +59 -0
  4. package/bin/gsap-motion.mjs +304 -0
  5. package/package.json +48 -0
  6. package/skill/LICENSE +21 -0
  7. package/skill/NOTICE.md +24 -0
  8. package/skill/SKILL.md +181 -0
  9. package/skill/example/cinematic-intro.md +202 -0
  10. package/skill/example/magnetic-button.md +101 -0
  11. package/skill/example/svg-loader.md +180 -0
  12. package/skill/preset/card-stack.md +175 -0
  13. package/skill/preset/cinematic.md +140 -0
  14. package/skill/preset/cursor.md +184 -0
  15. package/skill/preset/loader.md +172 -0
  16. package/skill/preset/magnetic.md +155 -0
  17. package/skill/preset/marquee.md +177 -0
  18. package/skill/preset/page-transition.md +151 -0
  19. package/skill/preset/reveal.md +146 -0
  20. package/skill/preset/scroll-storytelling.md +176 -0
  21. package/skill/reference/accessibility.md +143 -0
  22. package/skill/reference/core-gsap.md +233 -0
  23. package/skill/reference/flip.md +220 -0
  24. package/skill/reference/frameworks.md +153 -0
  25. package/skill/reference/interaction.md +183 -0
  26. package/skill/reference/motion-design.md +144 -0
  27. package/skill/reference/performance.md +216 -0
  28. package/skill/reference/project-rules.md +178 -0
  29. package/skill/reference/react-nextjs.md +234 -0
  30. package/skill/reference/routing.md +119 -0
  31. package/skill/reference/scrolltrigger.md +191 -0
  32. package/skill/reference/svg.md +201 -0
  33. package/skill/reference/text.md +150 -0
  34. package/skill/reference/three-r3f.md +161 -0
  35. package/skill/reference/timeline.md +147 -0
  36. package/skill/scripts/audit-gsap.mjs +157 -0
  37. package/skill/scripts/audit-svg.mjs +358 -0
  38. package/skill/scripts/lib/rules.mjs +555 -0
  39. package/skill/scripts/lib/source.mjs +238 -0
  40. package/skill/template/useScrollScene.ts +126 -0
  41. package/skill/template/useTimeline.ts +110 -0
@@ -0,0 +1,220 @@
1
+ # Flip
2
+
3
+ Animates between two layouts you did not have to compute. You change the DOM or
4
+ the CSS however you like, and Flip works out the transform that makes the old
5
+ state look like the new one, then animates it away.
6
+
7
+ Use it when the animation's meaning is **continuity** — this thing is still that
8
+ thing. A thumbnail becoming a hero image, a card expanding into a panel, an item
9
+ moving between lists, a grid reflowing on filter.
10
+
11
+ ```ts
12
+ import { Flip } from "gsap/Flip";
13
+ gsap.registerPlugin(Flip);
14
+ ```
15
+
16
+ ## The pattern
17
+
18
+ Three steps, always in this order:
19
+
20
+ ```ts
21
+ const state = Flip.getState("[data-card]"); // 1. record
22
+
23
+ container.classList.toggle("expanded"); // 2. change — anything at all
24
+
25
+ Flip.from(state, { // 3. animate the difference
26
+ duration: 0.6,
27
+ ease: "power3.inOut",
28
+ absolute: true,
29
+ });
30
+ ```
31
+
32
+ Step 2 can be a class toggle, a React re-render, a reparent, a sort — Flip does
33
+ not care how the layout changed, only what it was and what it is.
34
+
35
+ ## Options that matter
36
+
37
+ ```ts
38
+ Flip.from(state, {
39
+ duration: 0.6,
40
+ ease: "power3.inOut",
41
+ absolute: true, // position: absolute during the flip
42
+ nested: true, // children also move — compensates their transforms
43
+ scale: true, // scale instead of animating width/height
44
+ stagger: 0.05,
45
+ spin: true,
46
+ simple: true, // skip rotation/skew maths when there is none
47
+ onEnter: (els) => gsap.fromTo(els, { opacity: 0 }, { opacity: 1 }),
48
+ onLeave: (els) => gsap.to(els, { opacity: 0 }),
49
+ });
50
+ ```
51
+
52
+ - **`absolute: true`** takes the moving elements out of flow for the duration.
53
+ Without it, siblings reflow during the animation and the whole grid jitters.
54
+ Reach for it whenever more than one element moves.
55
+ - **`scale: true`** animates a transform instead of `width`/`height`. That is a
56
+ GPU-friendly property against a layout-triggering one — almost always the
57
+ right choice. The cost is that borders and text scale too; for a card with a
58
+ 1px border, animating size may look better.
59
+ - **`nested: true`** when the flipped elements contain their own flipped
60
+ children, so the child transform is compensated rather than compounded.
61
+ - **`onEnter` / `onLeave`** handle elements that did not exist in one of the two
62
+ states. Flip cannot interpolate something that has no "before" — these are
63
+ where you fade them.
64
+
65
+ ## Shared element between routes or views
66
+
67
+ The element must be identifiable in both states. `data-flip-id` is how Flip
68
+ matches a node in the old state to a *different* node in the new one:
69
+
70
+ ```tsx
71
+ <img data-flip-id="hero" … /> // in the grid
72
+ <img data-flip-id="hero" … /> // in the detail view
73
+ ```
74
+
75
+ ```ts
76
+ const state = Flip.getState("[data-flip-id]");
77
+ setOpen(true); // React swaps the tree
78
+ Flip.from(state, { duration: 0.6, absolute: true });
79
+ ```
80
+
81
+ In React, run `Flip.from` **after** the DOM has updated — in a layout effect
82
+ keyed on the state that changed, so it measures the new layout before paint:
83
+
84
+ ```ts
85
+ useGSAP(() => {
86
+ if (!state.current) return;
87
+ Flip.from(state.current, { duration: 0.6, absolute: true });
88
+ }, { dependencies: [open] });
89
+ ```
90
+
91
+ ## Filtering a grid
92
+
93
+ The case Flip is best at, and where hand-rolling is worst:
94
+
95
+ ```ts
96
+ const state = Flip.getState(items);
97
+ items.forEach((el) => {
98
+ el.style.display = el.dataset.tag === filter || filter === "all" ? "" : "none";
99
+ });
100
+ Flip.from(state, {
101
+ duration: 0.5,
102
+ scale: true,
103
+ absolute: true,
104
+ ease: "power2.inOut",
105
+ onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 }),
106
+ onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.9 }),
107
+ });
108
+ ```
109
+
110
+ ## Holding the container's height
111
+
112
+ `absolute: true` lifts every target out of flow, so a container that took its
113
+ height from them collapses to nothing and the whole page below jumps up. Flip's
114
+ own docs prescribe holding the height and tweening it alongside:
115
+
116
+ ```ts
117
+ tl.fromTo(grid, { height: fromHeight }, { height: toHeight, duration: D }, 0);
118
+ ```
119
+
120
+ **Release it on Flip's completion, never on the parent timeline's.** Flip
121
+ restores the targets to flow as the last child of its own timeline
122
+ (`_setFinalStates`, added with `animation.call()` at its end — internals,
123
+ verified against gsap 3.15), and its
124
+ `onComplete` fires immediately after that:
125
+
126
+ ```ts
127
+ tl.add(
128
+ Flip.from(state, {
129
+ targets: shells,
130
+ absolute: true,
131
+ onComplete: () => gsap.set(grid, { clearProps: "height" }),
132
+ }),
133
+ 0,
134
+ );
135
+ ```
136
+
137
+ Hanging it on the parent's `onComplete` only looks equivalent. The parent ends
138
+ when its **longest** child ends, so the gap between "Flip put things back in
139
+ flow" and "the height was released" becomes whatever else happens to be on that
140
+ timeline. Add a long sibling and it works; take that sibling away — a branch
141
+ that skipped, an async tween that never got added — and the release lands on the
142
+ same frame as the restore, for a one-frame collapse that only reproduces on the
143
+ path where the sibling is missing. A card-to-panel Flip had exactly this:
144
+ correct on every click but the first, where an image had not decoded and a
145
+ sibling image pass was never added.
146
+
147
+ The general rule: **tie the release of a held layout property to the event that
148
+ actually restores the layout, not to a timeline whose length is incidental.**
149
+
150
+ ### The held height distorts the layout Flip is about to measure
151
+
152
+ This one is worse, because it produces a wrong animation rather than a flicker.
153
+
154
+ `gsap.fromTo(grid, { height: from }, { height: to })` renders its start value
155
+ immediately, so by the time `Flip.from` measures where the targets should land,
156
+ the container is already pinned to the **old** height. A grid or flex container
157
+ that is taller than its content **stretches its auto rows to fill it** —
158
+ `align-content` defaults to `normal`, which behaves as `stretch`. Flip therefore
159
+ measures a stretched layout, animates everything to those positions, and the
160
+ elements snap when it releases them into the real one.
161
+
162
+ ```
163
+ held 744, natural 632, two rows → each row +56px
164
+ Flip's target: 138 + 476 + 24 = 638
165
+ where they go: 138 + 420 + 24 = 582 ← a 56px snap on release
166
+ ```
167
+
168
+ The fix is on the container, not the animation:
169
+
170
+ ```tsx
171
+ <ul className="grid content-start gap-6"> {/* align-content: start */}
172
+ ```
173
+
174
+ The held height then sits as space at the foot instead of inflating every row,
175
+ and it is inert whenever the height is not being held.
176
+
177
+ It reproduces only when the held height **exceeds** the natural one — opening a
178
+ panel that makes the grid shorter. Close it again, or switch between two open
179
+ states, and the container is shorter than its content, nothing stretches, and
180
+ the bug hides. That asymmetry is why it reads as "only on the first click".
181
+
182
+ ## `Flip.fit`
183
+
184
+ Makes one element match another's position and size without changing the DOM —
185
+ useful for an overlay that must land exactly on a card:
186
+
187
+ ```ts
188
+ Flip.fit(overlay, card, { duration: 0.5, scale: true, ease: "power3.inOut" });
189
+ ```
190
+
191
+ ## When not to use it
192
+
193
+ - **A simple show/hide.** Flip measures every target twice; a fade does not need
194
+ that.
195
+ - **Something that is not the same object.** If the visitor would not say "that
196
+ became this", continuity is the wrong story and Flip is just an expensive
197
+ transition.
198
+ - **Very large sets.** Two full layout reads plus a transform per element. A
199
+ hundred items is fine; a thousand is not.
200
+
201
+ ## Accessibility
202
+
203
+ Flip moves things across the screen, which is exactly the motion
204
+ `prefers-reduced-motion` exists for. Under `reduce`, apply the layout change
205
+ with `duration: 0` — the state still changes, the travel does not. Never skip
206
+ the change itself.
207
+
208
+ Focus stays on the element being flipped, which is correct. Just make sure the
209
+ focus ring is not clipped by an `overflow: hidden` ancestor during the flip.
210
+
211
+ ## Failures
212
+
213
+ | Symptom | Cause |
214
+ |---|---|
215
+ | Siblings jitter during the flip | missing `absolute: true` |
216
+ | Nothing animates | state captured after the change, not before |
217
+ | New items pop in | no `onEnter` |
218
+ | Children double-transform | missing `nested: true` |
219
+ | Blurry text mid-flight | `scale: true` on text-heavy elements — animate size |
220
+ | Wrong in React | `Flip.from` ran before the DOM updated |
@@ -0,0 +1,153 @@
1
+ # Frameworks other than React
2
+
3
+ GSAP has no framework. What changes between frameworks is only **where an
4
+ animation is created and where it is torn down** — the lifecycle. Everything
5
+ else in this skill applies unchanged: motion design, timelines, ScrollTrigger,
6
+ SVG, text, Flip, performance and accessibility.
7
+
8
+ One pattern sits under every framework below:
9
+
10
+ 1. Create animations inside `gsap.context(fn, root)`, so selector strings
11
+ resolve inside the component's own root element.
12
+ 2. Build the reduced-motion branch with `gsap.matchMedia()` inside that context,
13
+ so it goes with it.
14
+ 3. Call `ctx.revert()` when the component goes away. That undoes every tween,
15
+ ScrollTrigger, SplitText and matchMedia created inside, and restores inline
16
+ styles.
17
+
18
+ Timers, raw `addEventListener` calls and observers are not GSAP's — clear them
19
+ yourself in the same cleanup.
20
+
21
+ ## Vanilla JavaScript
22
+
23
+ ```js
24
+ import gsap from "gsap";
25
+ import { ScrollTrigger } from "gsap/ScrollTrigger";
26
+
27
+ gsap.registerPlugin(ScrollTrigger);
28
+
29
+ export function mountReveal(root) {
30
+ const ctx = gsap.context(() => {
31
+ const mm = gsap.matchMedia();
32
+ mm.add(
33
+ {
34
+ motion: "(prefers-reduced-motion: no-preference)",
35
+ reduced: "(prefers-reduced-motion: reduce)",
36
+ },
37
+ ({ conditions }) => {
38
+ if (conditions.reduced) {
39
+ gsap.set("[data-item]", { autoAlpha: 1, y: 0 });
40
+ return;
41
+ }
42
+ gsap.from("[data-item]", {
43
+ autoAlpha: 0,
44
+ y: 24,
45
+ stagger: { amount: 0.4 },
46
+ scrollTrigger: { trigger: root, start: "top 75%" },
47
+ });
48
+ },
49
+ );
50
+ }, root);
51
+
52
+ return () => ctx.revert(); // call when root is removed
53
+ }
54
+ ```
55
+
56
+ A static page that never removes the element can skip the cleanup. Anything that
57
+ swaps content — a client-side router, HTMX, Turbo, a modal — cannot.
58
+
59
+ ## Vue 3 and Nuxt
60
+
61
+ ```vue
62
+ <script setup>
63
+ import gsap from "gsap";
64
+ import { onMounted, onUnmounted, ref } from "vue";
65
+
66
+ const root = ref(null);
67
+ let ctx;
68
+
69
+ onMounted(() => {
70
+ ctx = gsap.context(() => {
71
+ gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
72
+ }, root.value);
73
+ });
74
+
75
+ onUnmounted(() => ctx?.revert());
76
+ </script>
77
+
78
+ <template>
79
+ <section ref="root">…</section>
80
+ </template>
81
+ ```
82
+
83
+ - `onMounted` never runs during server rendering, so this is safe under Nuxt as
84
+ written.
85
+ - Animation that reacts to state belongs in a `watch` that adds its tweens to
86
+ the same context with `ctx.add(() => { … })`, so they are reverted with it.
87
+
88
+ ## Svelte
89
+
90
+ ```svelte
91
+ <script>
92
+ import gsap from "gsap";
93
+ import { onMount } from "svelte";
94
+
95
+ let root;
96
+
97
+ onMount(() => {
98
+ const ctx = gsap.context(() => {
99
+ gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
100
+ }, root);
101
+ return () => ctx.revert();
102
+ });
103
+ </script>
104
+
105
+ <section bind:this={root}>…</section>
106
+ ```
107
+
108
+ A function returned from `onMount` runs when the component is destroyed.
109
+ `onMount` does not run during server rendering. The markup uses Svelte's
110
+ classic syntax; check how your Svelte version declares a `bind:this` variable
111
+ before copying it.
112
+
113
+ ## Astro
114
+
115
+ An Astro `<script>` runs once. With view transitions enabled, the page is
116
+ swapped without a reload, so build on each page load and revert before each
117
+ swap:
118
+
119
+ ```astro
120
+ <script>
121
+ import gsap from "gsap";
122
+
123
+ let ctx;
124
+
125
+ document.addEventListener("astro:page-load", () => {
126
+ ctx = gsap.context(() => {
127
+ gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
128
+ });
129
+ });
130
+
131
+ document.addEventListener("astro:before-swap", () => ctx?.revert());
132
+ </script>
133
+ ```
134
+
135
+ Inside a React, Vue or Svelte island, use that framework's section instead.
136
+
137
+ ## Mounting twice
138
+
139
+ Selector strings are scoped to the context's root, but `motionPath.path` and
140
+ `morphSVG.shape` are resolved by the plugins against the whole document. A
141
+ component rendered twice with a hardcoded id drives the first instance's
142
+ geometry. Generate an id per instance in every framework, not only React.
143
+
144
+ ## What the audit covers here
145
+
146
+ - `audit-gsap.mjs` reads `.js`, `.ts`, `.jsx`, `.tsx` and `.mjs` files only.
147
+ Logic inside `.vue`, `.svelte` or `.astro` files is not scanned — keep the
148
+ animation in a sibling module, like `mountReveal` above, if you want it
149
+ checked.
150
+ - Its React-specific rules — `orphan-tween`, `state-per-event` and
151
+ `shared-plugin-id` — apply only to React files. The rest apply to every file
152
+ it reads: cost, loops, scrub, listeners, unmanaged instances, plugin
153
+ registration, reduced motion and shipped dev tooling.
@@ -0,0 +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 |
@@ -0,0 +1,144 @@
1
+ # Motion design
2
+
3
+ The craft half. Read this when an animation compiles, runs, and still feels
4
+ wrong — the fix is almost always here rather than in the API.
5
+
6
+ ## What motion is for
7
+
8
+ Every animation earns its place by answering one of five questions. If it
9
+ answers none, delete it.
10
+
11
+ | It says | Example |
12
+ |---|---|
13
+ | **Hierarchy** — what matters | the heading arrives before the byline |
14
+ | **Causality** — this caused that | the panel grows out of the button that opened it |
15
+ | **Continuity** — it is the same thing | the thumbnail becomes the hero image |
16
+ | **State** — something changed | the row settles into place after saving |
17
+ | **Personality** — who this brand is | the mark writes itself rather than fading in |
18
+
19
+ "It looked empty" is not one of them.
20
+
21
+ ## Duration
22
+
23
+ Numbers people actually perceive, not a scale copied from a blog post.
24
+
25
+ | Range | Reads as | Use for |
26
+ |---|---|---|
27
+ | 80–150ms | instant | hover, focus, toggle, press |
28
+ | 200–300ms | responsive | dropdowns, tooltips, small reveals |
29
+ | 350–500ms | deliberate | modals, panels, section reveals |
30
+ | 600–900ms | considered | hero entrances, page transitions |
31
+ | 1s+ | a performance | intros, loaders, scroll-scrubbed sequences |
32
+
33
+ Two rules bend all of these. **Distance buys time**: something crossing the
34
+ viewport may take three times as long as the same element moving 20px. And
35
+ **exits are faster than entrances** — usually 60–70% — because the visitor has
36
+ already decided and is waiting.
37
+
38
+ ## Easing
39
+
40
+ The single biggest lever on how motion feels, and the one most often left at the
41
+ default.
42
+
43
+ | Ease | Feel | Use |
44
+ |---|---|---|
45
+ | `power2.out` | quick start, soft landing | the default for entrances |
46
+ | `power3.out` / `power4.out` | sharper, more expensive-feeling | premium, cinematic |
47
+ | `power2.in` | gathers then leaves | exits |
48
+ | `power2.inOut` | symmetric | moves between two resting states |
49
+ | `none` | mechanical, constant | **anything looping**, scrub, marquee |
50
+ | `back.out(1.4)` | overshoot | playful — never on more than one thing at a time |
51
+ | `elastic.out(1, 0.4)` | wobble | rarely; reads as a toy |
52
+ | `sine.inOut` | gentle breath | ambient loops, pulses |
53
+ | `expo.out` | dramatic arrival | one hero moment, not a list |
54
+
55
+ Two failures worth naming. **An ease other than `none` on a repeating tween**
56
+ puts a visible stutter at the seam — the loop decelerates into its own restart.
57
+ And **`inOut` on an entrance** makes it feel like it is being pushed rather than
58
+ arriving; entrances want `out`.
59
+
60
+ A house ease beats a stock one. `CustomEase` is free:
61
+
62
+ ```ts
63
+ CustomEase.create("brand", "M0,0 C0.16,1 0.3,1 1,1");
64
+ ```
65
+
66
+ ## Rhythm
67
+
68
+ Stagger is where amateur motion shows. The wrong value makes a list look either
69
+ simultaneous or like a queue.
70
+
71
+ - **0.03–0.06s** — a wave. Reads as one gesture across many items.
72
+ - **0.08–0.12s** — countable. The eye tracks each item. Best for 3–6 items.
73
+ - **0.15s+** — a sequence of separate events. Only for a handful.
74
+
75
+ For more than about eight items, stagger a *total* rather than a per-item gap,
76
+ so twelve cards do not take four seconds:
77
+
78
+ ```ts
79
+ stagger: { amount: 0.6, from: "start" } // 0.6s total, however many items
80
+ stagger: { each: 0.08 } // 0.08s apart, grows without limit
81
+ ```
82
+
83
+ `from: "center"` and `from: "edges"` change the story: centre-out reads as
84
+ radiating, start-to-end reads as reading order. In RTL, "reading order" is
85
+ `from: "end"` — see [project-rules.md](project-rules.md).
86
+
87
+ ## Overlap
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.
91
+
92
+ ```ts
93
+ tl.to(a, { ... })
94
+ .to(b, { ... }, "-=0.3") // b starts before a finishes
95
+ .to(c, { ... }, "<") // c starts with b
96
+ .to(d, { ... }, "<0.15"); // d starts 0.15s after b started
97
+ ```
98
+
99
+ A useful default: the next beat starts at **60–75%** of the previous one. Full
100
+ sequence only when the second thing is genuinely *caused* by the first
101
+ finishing.
102
+
103
+ ## The principles that matter here
104
+
105
+ - **Weight** — a large or heavy-looking element should move slower and ease
106
+ harder than a small one. Same duration on a hero image and a caption reads as
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.
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.
112
+ - **Contrast** — fast against slow is what creates emphasis. If everything moves
113
+ at 400ms, nothing is emphasised.
114
+ - **Restraint** — the most premium-feeling motion is usually one or two things
115
+ moving well, not six things moving at all.
116
+
117
+ ## Reading a request
118
+
119
+ | They say | They usually mean |
120
+ |---|---|
121
+ | "smoother" | longer duration, softer ease — or a cost problem; check which |
122
+ | "snappier" | shorter duration, `power3.out`, less stagger |
123
+ | "more premium" | fewer elements moving, longer eases, no overshoot |
124
+ | "more playful" | overshoot on one element, tighter stagger |
125
+ | "cinematic" | slower, sequential, one thing at a time, generous holds |
126
+ | "subtle" | reduce distance before reducing duration |
127
+ | "it feels cheap" | usually linear easing or uniform duration |
128
+ | "it feels slow" | usually too sequential, not too long — try overlap first |
129
+
130
+ ## Validation
131
+
132
+ Before calling an animation done:
133
+
134
+ - Does it say one of the five things at the top of this file?
135
+ - Is the rhythm intentional, or are the delays arbitrary?
136
+ - Does anything looping use `ease: "none"`?
137
+ - Is the seam of the loop invisible?
138
+ - Do entrances use `out` easing and exits `in`?
139
+ - Does one element carry the emphasis, rather than all of them?
140
+ - Does it still read at 400px wide, and on a touch device with no hover?
141
+ - Does the reduced-motion branch still communicate the same thing?
142
+ - Is everything reverted on unmount?
143
+ - Is every plugin justified, and would a transform have done it?
144
+ - Watch it five times. Does anything start to irritate?