@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,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 |
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Marquee and ambient
|
|
2
|
+
|
|
3
|
+
Continuous motion. The engineering problem is the seam: a loop whose restart is
|
|
4
|
+
visible is worse than no loop at all.
|
|
5
|
+
|
|
6
|
+
## Seamless marquee
|
|
7
|
+
|
|
8
|
+
Duplicate the content, translate by exactly one copy's width, reset. Because the
|
|
9
|
+
two copies are identical, the reset is a jump between identical frames.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
<div className="overflow-hidden">
|
|
13
|
+
<div ref={trackRef} className="flex w-max">
|
|
14
|
+
<div data-copy className="flex shrink-0">{items}</div>
|
|
15
|
+
<div data-copy className="flex shrink-0" aria-hidden="true">{items}</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
useGSAP(() => {
|
|
22
|
+
const copy = trackRef.current?.querySelector<HTMLElement>("[data-copy]");
|
|
23
|
+
if (!copy) return;
|
|
24
|
+
|
|
25
|
+
const loop = gsap.to(trackRef.current, {
|
|
26
|
+
x: () => -copy.offsetWidth,
|
|
27
|
+
duration: copy.offsetWidth / 60, // px per second, not a fixed time
|
|
28
|
+
ease: "none",
|
|
29
|
+
repeat: -1,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Re-reads the width after a resize or a font load. */
|
|
33
|
+
const ro = new ResizeObserver(() => loop.invalidate());
|
|
34
|
+
ro.observe(copy);
|
|
35
|
+
|
|
36
|
+
return () => ro.disconnect();
|
|
37
|
+
}, { scope: trackRef });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Three things that are all load-bearing:
|
|
41
|
+
|
|
42
|
+
- **`ease: "none"`.** Any other ease decelerates into the restart and the seam
|
|
43
|
+
becomes visible.
|
|
44
|
+
- **`x` as a function.** It is re-evaluated on `invalidate()`, which is what
|
|
45
|
+
survives a resize. A fixed number goes wrong the moment the layout changes.
|
|
46
|
+
- **Duration derived from width.** A fixed duration means a long list scrolls
|
|
47
|
+
fast and a short one crawls. Pick a speed in px/sec and divide.
|
|
48
|
+
|
|
49
|
+
The second copy is `aria-hidden` — a screen reader should hear the list once.
|
|
50
|
+
|
|
51
|
+
### RTL
|
|
52
|
+
|
|
53
|
+
A marquee has a handedness, so a right-to-left page needs a decision. Either
|
|
54
|
+
mirror it, or pin the direction deliberately and say why:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const isRtl = document.documentElement.dir === "rtl";
|
|
58
|
+
x: () => (isRtl ? copy.offsetWidth : -copy.offsetWidth);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Logos and product shots usually read better pinned to one direction in both
|
|
62
|
+
locales. If the project lints for logical CSS, that pin needs its waiver
|
|
63
|
+
comment, carrying the reason — see [project-rules.md](../reference/project-rules.md).
|
|
64
|
+
|
|
65
|
+
### Pausing
|
|
66
|
+
|
|
67
|
+
WCAG 2.2.2: anything moving for more than five seconds needs a way to stop it.
|
|
68
|
+
Pause on hover and on focus-within:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
el.addEventListener("pointerenter", () => loop.timeScale(0.2));
|
|
72
|
+
el.addEventListener("pointerleave", () => loop.timeScale(1));
|
|
73
|
+
el.addEventListener("focusin", () => loop.pause());
|
|
74
|
+
el.addEventListener("focusout", () => loop.resume());
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Slowing rather than stopping on hover keeps it alive while making a logo
|
|
78
|
+
readable. Focus must actually stop it — a keyboard user cannot chase a moving
|
|
79
|
+
link.
|
|
80
|
+
|
|
81
|
+
## Scroll-reactive speed
|
|
82
|
+
|
|
83
|
+
Ties the marquee to the page and costs almost nothing:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
ScrollTrigger.create({
|
|
87
|
+
onUpdate: (self) => {
|
|
88
|
+
loop.timeScale(gsap.utils.clamp(0.4, 3, 1 + Math.abs(self.getVelocity()) / 1500));
|
|
89
|
+
// Direction follows the scroll:
|
|
90
|
+
loop.reversed(self.direction === -1);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Clamp it — unclamped velocity produces absurd values on a flick.
|
|
96
|
+
|
|
97
|
+
## Ambient float
|
|
98
|
+
|
|
99
|
+
Background motion under static content. The rule is **irregularity**: identical
|
|
100
|
+
periods read as mechanical.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
gsap.utils.toArray<HTMLElement>("[data-float]").forEach((el, i) => {
|
|
104
|
+
gsap.to(el, {
|
|
105
|
+
y: gsap.utils.random(-14, -26),
|
|
106
|
+
x: gsap.utils.random(-8, 8),
|
|
107
|
+
duration: gsap.utils.random(3.5, 6),
|
|
108
|
+
repeat: -1,
|
|
109
|
+
yoyo: true,
|
|
110
|
+
ease: "sine.inOut",
|
|
111
|
+
delay: i * 0.3,
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`sine.inOut` is the breath ease — it has no hard stop at either end.
|
|
117
|
+
`yoyo: true` reverses rather than resetting, so there is no seam to hide.
|
|
118
|
+
|
|
119
|
+
Keep the distance small. Ambient motion is felt, not watched; if the visitor can
|
|
120
|
+
track it, it is too much.
|
|
121
|
+
|
|
122
|
+
## Orbit
|
|
123
|
+
|
|
124
|
+
An element circling a point. Rotating a wrapper is cheaper than a motion path
|
|
125
|
+
when the route is a circle:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
<div data-orbit className="absolute inset-0">
|
|
129
|
+
<div className="absolute start-1/2 top-0 size-2 rounded-full" />
|
|
130
|
+
</div>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
gsap.to("[data-orbit]", { rotation: 360, duration: 8, ease: "none", repeat: -1 });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use MotionPath only when the route is not a circle — see
|
|
138
|
+
[svg.md](../reference/svg.md).
|
|
139
|
+
|
|
140
|
+
## Breathing
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
gsap.to("[data-pulse]", {
|
|
144
|
+
scale: 1.04,
|
|
145
|
+
autoAlpha: 0.85,
|
|
146
|
+
duration: 2.2,
|
|
147
|
+
repeat: -1,
|
|
148
|
+
yoyo: true,
|
|
149
|
+
ease: "sine.inOut",
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Two seconds is roughly resting breath and reads as calm. Under a second reads as
|
|
154
|
+
urgency — correct for a recording indicator, wrong for a background.
|
|
155
|
+
|
|
156
|
+
## Cost
|
|
157
|
+
|
|
158
|
+
Continuous animation never stops, so it is the one category where cost
|
|
159
|
+
compounds. Only `transform` and `opacity`. Pause anything off-screen:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
ScrollTrigger.create({
|
|
163
|
+
trigger: section,
|
|
164
|
+
onToggle: (self) => (self.isActive ? loop.resume() : loop.pause()),
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A dozen paused timelines cost nothing; a dozen running ones on a phone do.
|
|
169
|
+
|
|
170
|
+
## Reduced motion
|
|
171
|
+
|
|
172
|
+
Ambient motion is unrequested and continuous — exactly what the preference is
|
|
173
|
+
for. Stop it entirely and leave the composition static; nothing is lost, because
|
|
174
|
+
ambient motion carries no information by definition.
|
|
175
|
+
|
|
176
|
+
A marquee is different: it may be the only way to see the whole list. Keep the
|
|
177
|
+
content reachable — make it a scrollable row rather than a moving one.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Page and modal transitions
|
|
2
|
+
|
|
3
|
+
The App Router unmounts the old tree before the new one paints, so an exit
|
|
4
|
+
animation has nowhere to play. Every approach below is a way around that fact.
|
|
5
|
+
|
|
6
|
+
## Pick the cheapest that works
|
|
7
|
+
|
|
8
|
+
| Approach | Cost | Use when |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| Entrance only | trivial | almost always — this is what most sites want |
|
|
11
|
+
| Overlay in the layout | moderate | you need a real cover between pages |
|
|
12
|
+
| Shared element (Flip) | high | continuity is the point: a thumbnail becoming a hero |
|
|
13
|
+
| Intercepted navigation | high, breaks back-button feel | rarely |
|
|
14
|
+
|
|
15
|
+
Start at the top. A 400ms entrance keyed off the pathname is indistinguishable
|
|
16
|
+
from a "real" transition for most content, and costs nothing in complexity.
|
|
17
|
+
|
|
18
|
+
## Entrance only
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
"use client";
|
|
22
|
+
|
|
23
|
+
import { useGSAP } from "@gsap/react";
|
|
24
|
+
import gsap from "gsap";
|
|
25
|
+
import { usePathname } from "next/navigation";
|
|
26
|
+
import { useRef } from "react";
|
|
27
|
+
|
|
28
|
+
export function PageEnter({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
29
|
+
const root = useRef<HTMLDivElement>(null);
|
|
30
|
+
const pathname = usePathname();
|
|
31
|
+
|
|
32
|
+
useGSAP(
|
|
33
|
+
() => {
|
|
34
|
+
gsap.from(root.current, {
|
|
35
|
+
autoAlpha: 0,
|
|
36
|
+
y: 12,
|
|
37
|
+
duration: 0.5,
|
|
38
|
+
ease: "power2.out",
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
{ scope: root, dependencies: [pathname] },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return <div ref={root}>{children}</div>;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`dependencies: [pathname]` re-runs it per navigation. Keep the travel small —
|
|
49
|
+
the visitor asked for this page and is waiting.
|
|
50
|
+
|
|
51
|
+
## Overlay in the layout
|
|
52
|
+
|
|
53
|
+
Mounted in the layout so the navigation does not unmount it. It covers, the
|
|
54
|
+
route changes underneath, it uncovers.
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// In the locale layout, above {children}
|
|
58
|
+
<RouteCurtain />
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const pathname = usePathname();
|
|
63
|
+
|
|
64
|
+
useGSAP(() => {
|
|
65
|
+
gsap.timeline()
|
|
66
|
+
.set(curtain, { yPercent: 100 })
|
|
67
|
+
.to(curtain, { yPercent: 0, duration: 0.45, ease: "power3.in" })
|
|
68
|
+
.to(curtain, { yPercent: -100, duration: 0.55, ease: "power3.out" }, "+=0.05");
|
|
69
|
+
}, { dependencies: [pathname] });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This covers *after* the new route has rendered, which is a compromise — it reads
|
|
73
|
+
as a wipe rather than a true out-and-in. Honest and robust. `pointer-events:
|
|
74
|
+
none` on the curtain except while it covers, and `aria-hidden` throughout.
|
|
75
|
+
|
|
76
|
+
## Shared element
|
|
77
|
+
|
|
78
|
+
Use Flip. The element must be identifiable in both trees via `data-flip-id` —
|
|
79
|
+
see [flip.md](../reference/flip.md).
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// Before navigating, from the grid:
|
|
83
|
+
const state = Flip.getState("[data-flip-id]");
|
|
84
|
+
sessionStorage.setItem("flip", JSON.stringify(state)); // survives the route
|
|
85
|
+
|
|
86
|
+
// On the detail page, in a layout effect:
|
|
87
|
+
Flip.from(state, { duration: 0.6, absolute: true, ease: "power3.inOut" });
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
In practice this is fragile across a real route change — the image must be
|
|
91
|
+
loaded in both places, and a cache miss produces a jump. It works reliably
|
|
92
|
+
inside a single route (a grid expanding into a panel) and is worth the cost
|
|
93
|
+
there.
|
|
94
|
+
|
|
95
|
+
## Modal
|
|
96
|
+
|
|
97
|
+
The common case, and simpler because nothing unmounts unexpectedly.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const open = gsap.timeline({ paused: true })
|
|
101
|
+
.set(overlay, { autoAlpha: 0 })
|
|
102
|
+
.to(overlay, { autoAlpha: 1, duration: 0.25 })
|
|
103
|
+
.from(panel, { yPercent: 4, scale: 0.98, autoAlpha: 0,
|
|
104
|
+
duration: 0.35, ease: "power3.out" }, "<0.05");
|
|
105
|
+
|
|
106
|
+
// Open: open.play()
|
|
107
|
+
// Close: open.reverse() — same easing backwards, no second definition
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Exits run faster than entrances — the visitor has already decided. If you write
|
|
111
|
+
the close by hand rather than reversing, make it ~65% of the open duration.
|
|
112
|
+
|
|
113
|
+
Accessibility is the bigger half of a modal:
|
|
114
|
+
|
|
115
|
+
- Move focus into the panel on open, restore it to the trigger on close.
|
|
116
|
+
- Trap focus while open; `inert` on the rest of the document.
|
|
117
|
+
- `Escape` closes.
|
|
118
|
+
- `role="dialog"` and `aria-modal="true"` with an accessible name.
|
|
119
|
+
|
|
120
|
+
Animation must not delay any of that. Set focus immediately, not in
|
|
121
|
+
`onComplete` — a 350ms wait for a keyboard user is a bug.
|
|
122
|
+
|
|
123
|
+
## Reduced motion
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
if (reduced) {
|
|
127
|
+
gsap.set(panel, { autoAlpha: 1, yPercent: 0, scale: 1 });
|
|
128
|
+
gsap.set(overlay, { autoAlpha: 1 });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The state change still happens; only the travel goes. For a page transition
|
|
134
|
+
under `reduce`, skip the curtain entirely — a full-screen wipe on every
|
|
135
|
+
navigation is exactly the unrequested movement the preference exists to stop.
|
|
136
|
+
|
|
137
|
+
## After navigating
|
|
138
|
+
|
|
139
|
+
`ScrollTrigger.refresh()` once the new layout has settled, or every trigger on
|
|
140
|
+
the new page measures against the old one.
|
|
141
|
+
|
|
142
|
+
## Failures
|
|
143
|
+
|
|
144
|
+
| Symptom | Cause |
|
|
145
|
+
|---|---|
|
|
146
|
+
| Exit animation never plays | the route unmounted the tree — use an overlay |
|
|
147
|
+
| Curtain traps clicks | `pointer-events` not cleared |
|
|
148
|
+
| Flip jumps on the detail page | the image was not loaded when measured |
|
|
149
|
+
| Triggers wrong after navigation | no `ScrollTrigger.refresh()` |
|
|
150
|
+
| Focus lost after a modal closes | not restored to the trigger |
|
|
151
|
+
| Transition replays on every render | missing `dependencies`, or the wrong one |
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Reveal
|
|
2
|
+
|
|
3
|
+
Entrance and in-view reveals. The most common request and the easiest to make
|
|
4
|
+
generic — the difference between a stock reveal and a good one is the mask and
|
|
5
|
+
the rhythm, not the duration.
|
|
6
|
+
|
|
7
|
+
## Distance over opacity
|
|
8
|
+
|
|
9
|
+
A 60px slide reads as arrival; a 200px slide reads as a slideshow. Start small.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
gsap.from("[data-item]", {
|
|
13
|
+
y: 32,
|
|
14
|
+
autoAlpha: 0,
|
|
15
|
+
duration: 0.7,
|
|
16
|
+
stagger: 0.06,
|
|
17
|
+
ease: "power3.out",
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`autoAlpha`, not `opacity`, so nothing invisible takes pointer events. For a
|
|
22
|
+
`from` tween outside a layout effect, set the start state first or it flashes.
|
|
23
|
+
|
|
24
|
+
## Mask reveal
|
|
25
|
+
|
|
26
|
+
The upgrade that makes a reveal look authored. The element rises out of nothing
|
|
27
|
+
instead of sliding in from somewhere.
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
<span className="block overflow-hidden">
|
|
31
|
+
<span data-line className="block">Built in Iran.</span>
|
|
32
|
+
</span>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
gsap.from("[data-line]", {
|
|
37
|
+
yPercent: 110,
|
|
38
|
+
duration: 0.8,
|
|
39
|
+
stagger: 0.08,
|
|
40
|
+
ease: "power3.out",
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`yPercent: 110` rather than `100` — a descender pokes below the box at exactly
|
|
45
|
+
100.
|
|
46
|
+
|
|
47
|
+
## Image reveal
|
|
48
|
+
|
|
49
|
+
Clip the container, counter-move the image. The image appears to be uncovered
|
|
50
|
+
rather than to slide, and the parallax inside the mask is what sells it.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
<div data-frame className="overflow-hidden">
|
|
54
|
+
<img data-img alt="" />
|
|
55
|
+
</div>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
gsap.timeline({ defaults: { ease: "power3.inOut", duration: 1 } })
|
|
60
|
+
.from("[data-frame]", { clipPath: "inset(0 0 100% 0)" })
|
|
61
|
+
.from("[data-img]", { scale: 1.25 }, "<");
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`clipPath` animates on the compositor in modern browsers. `scale` on the image
|
|
65
|
+
must be a counter-move — if both travel the same way it just looks like a zoom.
|
|
66
|
+
|
|
67
|
+
For RTL, a horizontal wipe direction must mirror: `inset(0 100% 0 0)` becomes
|
|
68
|
+
`inset(0 0 0 100%)`.
|
|
69
|
+
|
|
70
|
+
## In-view
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
gsap.from("[data-item]", {
|
|
74
|
+
y: 32,
|
|
75
|
+
autoAlpha: 0,
|
|
76
|
+
stagger: 0.06,
|
|
77
|
+
scrollTrigger: {
|
|
78
|
+
trigger: section,
|
|
79
|
+
start: "top 75%",
|
|
80
|
+
toggleActions: "play none none none",
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`"top 75%"` fires a quarter into the viewport — early enough that it is not
|
|
86
|
+
already read, late enough that it is not missed. `play none none none` plays
|
|
87
|
+
once; `play none none reverse` replays on scroll back, which is usually
|
|
88
|
+
irritating on a long page.
|
|
89
|
+
|
|
90
|
+
**One trigger for the group.** Forty cards is one trigger with a stagger, not
|
|
91
|
+
forty triggers — see [scrolltrigger.md](../reference/scrolltrigger.md).
|
|
92
|
+
|
|
93
|
+
## Stagger shapes
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
stagger: { amount: 0.5 } // 0.5s total, any count
|
|
97
|
+
stagger: { each: 0.06, from: "center" } // radiates outward
|
|
98
|
+
stagger: { each: 0.05, from: "end" } // reading order in RTL
|
|
99
|
+
stagger: { amount: 0.6, grid: [4, 3], from: "start" } // diagonal across a grid
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`amount` over `each` for anything that can grow — twelve cards at `each: 0.1`
|
|
103
|
+
take 1.2s of nothing happening.
|
|
104
|
+
|
|
105
|
+
## Composed section entrance
|
|
106
|
+
|
|
107
|
+
Hierarchy in time: the thing that matters arrives first.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
gsap.timeline({
|
|
111
|
+
defaults: { ease: "power3.out", duration: 0.8 },
|
|
112
|
+
scrollTrigger: { trigger: section, start: "top 70%" },
|
|
113
|
+
})
|
|
114
|
+
.from("[data-eyebrow]", { autoAlpha: 0, y: 12, duration: 0.5 })
|
|
115
|
+
.from("[data-heading-line]", { yPercent: 110, stagger: 0.08 }, "-=0.25")
|
|
116
|
+
.from("[data-body]", { autoAlpha: 0, y: 20 }, "-=0.5")
|
|
117
|
+
.from("[data-cta]", { autoAlpha: 0, y: 16, duration: 0.6 }, "-=0.45")
|
|
118
|
+
.from("[data-media]", { clipPath: "inset(0 0 100% 0)", duration: 1 }, "-=0.8");
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The negative offsets overlap each beat into the one before at roughly 60–70%.
|
|
122
|
+
Nothing waits for the previous thing to finish, which is what stops it reading
|
|
123
|
+
as a list.
|
|
124
|
+
|
|
125
|
+
## Reduced motion
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
if (reduced) {
|
|
129
|
+
gsap.set("[data-item]", { autoAlpha: 1, y: 0, clipPath: "none" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**The reduced branch must set the end state.** A `from` that never runs leaves
|
|
135
|
+
the element at its authored start — invisible. This hides content rather than
|
|
136
|
+
skipping motion, and it is the most common bug in this preset.
|
|
137
|
+
|
|
138
|
+
## Tuning
|
|
139
|
+
|
|
140
|
+
| Feels | Change |
|
|
141
|
+
|---|---|
|
|
142
|
+
| like a slideshow | overlap the beats — `<` or negative offsets |
|
|
143
|
+
| cheap | mask instead of fade; `power3.out` instead of `power1` |
|
|
144
|
+
| slow | reduce distance before duration; overlap more |
|
|
145
|
+
| chaotic | fewer things moving; one carries the emphasis |
|
|
146
|
+
| janky | `y` not `top`; check the trigger count |
|