@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.
- package/LICENSE +21 -0
- package/NOTICE.md +24 -0
- package/README.md +59 -0
- package/bin/gsap-motion.mjs +304 -0
- package/package.json +48 -0
- package/skill/LICENSE +21 -0
- package/skill/NOTICE.md +24 -0
- package/skill/SKILL.md +181 -0
- package/skill/example/cinematic-intro.md +202 -0
- package/skill/example/magnetic-button.md +101 -0
- package/skill/example/svg-loader.md +180 -0
- package/skill/preset/card-stack.md +175 -0
- package/skill/preset/cinematic.md +140 -0
- package/skill/preset/cursor.md +184 -0
- package/skill/preset/loader.md +172 -0
- package/skill/preset/magnetic.md +155 -0
- package/skill/preset/marquee.md +177 -0
- package/skill/preset/page-transition.md +151 -0
- package/skill/preset/reveal.md +146 -0
- package/skill/preset/scroll-storytelling.md +176 -0
- package/skill/reference/accessibility.md +143 -0
- package/skill/reference/core-gsap.md +233 -0
- package/skill/reference/flip.md +220 -0
- package/skill/reference/frameworks.md +153 -0
- package/skill/reference/interaction.md +183 -0
- package/skill/reference/motion-design.md +144 -0
- package/skill/reference/performance.md +216 -0
- package/skill/reference/project-rules.md +178 -0
- package/skill/reference/react-nextjs.md +234 -0
- package/skill/reference/routing.md +119 -0
- package/skill/reference/scrolltrigger.md +191 -0
- package/skill/reference/svg.md +201 -0
- package/skill/reference/text.md +150 -0
- package/skill/reference/three-r3f.md +161 -0
- package/skill/reference/timeline.md +147 -0
- package/skill/scripts/audit-gsap.mjs +157 -0
- package/skill/scripts/audit-svg.mjs +358 -0
- package/skill/scripts/lib/rules.mjs +555 -0
- package/skill/scripts/lib/source.mjs +238 -0
- package/skill/template/useScrollScene.ts +126 -0
- 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?
|