@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,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 |