@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,175 @@
1
+ # Card stack
2
+
3
+ Cards that arrive from a pile, stack as you scroll, or reflow on filter. Three
4
+ different stories — pick the one the content actually tells.
5
+
6
+ ## Deal in
7
+
8
+ A pile that spreads into place. Says "these belong together" before showing what
9
+ they are.
10
+
11
+ ```ts
12
+ gsap.timeline({
13
+ defaults: { duration: 0.8, ease: "power3.out" },
14
+ scrollTrigger: { trigger: grid, start: "top 70%" },
15
+ })
16
+ .from("[data-card]", {
17
+ y: 60,
18
+ scale: 0.92,
19
+ rotation: () => gsap.utils.random(-6, 6),
20
+ autoAlpha: 0,
21
+ stagger: { amount: 0.5, from: "center" },
22
+ });
23
+ ```
24
+
25
+ `rotation` as a function is evaluated per element, so each card gets its own
26
+ angle. `stagger: { amount }` keeps twelve cards inside half a second —
27
+ `each: 0.08` would take nearly a second of nothing happening.
28
+
29
+ Keep the rotation under ~8°. Beyond that it reads as a mess rather than a
30
+ shuffle.
31
+
32
+ ## Stacking on scroll
33
+
34
+ Cards pin and pile up as the visitor scrolls — one shared trigger, each card
35
+ offset. This is the "Apple pricing page" pattern.
36
+
37
+ ```ts
38
+ const cards = gsap.utils.toArray<HTMLElement>("[data-card]");
39
+
40
+ cards.forEach((card, i) => {
41
+ gsap.to(card, {
42
+ scale: 1 - (cards.length - i) * 0.03,
43
+ y: i * 12,
44
+ ease: "none",
45
+ scrollTrigger: {
46
+ trigger: card,
47
+ start: "top 20%",
48
+ end: "bottom 20%",
49
+ scrub: 1,
50
+ pin: true,
51
+ pinSpacing: false,
52
+ invalidateOnRefresh: true,
53
+ },
54
+ });
55
+ });
56
+
57
+ // The card itself must stick:
58
+ // .card { position: sticky; top: 5vh; }
59
+ ```
60
+
61
+ `pinSpacing: false` is what lets the cards overlap instead of each reserving its
62
+ own scroll height. This is the rare case where **one trigger per card is
63
+ correct** — each pins independently.
64
+
65
+ The scale is what creates depth: the card behind must read as further away, or
66
+ the stack looks like a rendering bug.
67
+
68
+ ## Explode out
69
+
70
+ Reverse of deal-in, for an exit or a filter change.
71
+
72
+ ```ts
73
+ gsap.to("[data-card]", {
74
+ y: () => gsap.utils.random(-120, 120),
75
+ x: () => gsap.utils.random(-80, 80),
76
+ rotation: () => gsap.utils.random(-25, 25),
77
+ autoAlpha: 0,
78
+ scale: 0.85,
79
+ duration: 0.6,
80
+ ease: "power2.in",
81
+ stagger: { amount: 0.25, from: "center" },
82
+ });
83
+ ```
84
+
85
+ `power2.in` for an exit — it gathers speed as it leaves, where `out` would make
86
+ it drift.
87
+
88
+ ## Reflow on filter
89
+
90
+ Use Flip. Hand-computing positions here is exactly the work Flip exists to
91
+ remove — see [flip.md](../reference/flip.md).
92
+
93
+ ```ts
94
+ const state = Flip.getState(cards);
95
+ cards.forEach((el) => {
96
+ el.style.display = matches(el) ? "" : "none";
97
+ });
98
+ Flip.from(state, {
99
+ duration: 0.5,
100
+ scale: true,
101
+ absolute: true,
102
+ ease: "power2.inOut",
103
+ stagger: 0.03,
104
+ onEnter: (els) => gsap.fromTo(els, { autoAlpha: 0, scale: 0.9 },
105
+ { autoAlpha: 1, scale: 1 }),
106
+ onLeave: (els) => gsap.to(els, { autoAlpha: 0, scale: 0.9 }),
107
+ });
108
+ ```
109
+
110
+ `absolute: true` or the surviving cards jitter as the grid reflows under them.
111
+
112
+ ## Hover lift
113
+
114
+ Small and immediate. The shadow is what sells the lift — a card that moves
115
+ without its shadow changing looks like it slid, not rose.
116
+
117
+ ```ts
118
+ const lift = gsap.timeline({ paused: true })
119
+ .to(card, { y: -6, duration: 0.3, ease: "power2.out" })
120
+ .to(card, { boxShadow: "var(--shadow-lifted)", duration: 0.3 }, "<");
121
+ ```
122
+
123
+ `box-shadow` repaints. For a grid of many cards, cross-fade two stacked shadow
124
+ layers with `opacity` instead.
125
+
126
+ ## Tilt
127
+
128
+ Pointer-driven rotation on the card's own axes:
129
+
130
+ ```ts
131
+ const rx = gsap.quickTo(card, "rotationX", { duration: 0.5, ease: "power3" });
132
+ const ry = gsap.quickTo(card, "rotationY", { duration: 0.5, ease: "power3" });
133
+
134
+ const onMove = (e: PointerEvent) => {
135
+ const r = card.getBoundingClientRect();
136
+ rx((0.5 - (e.clientY - r.top) / r.height) * 12);
137
+ ry(((e.clientX - r.left) / r.width - 0.5) * 12);
138
+ };
139
+ ```
140
+
141
+ Needs `perspective` on the parent, or the rotation is a flat squash:
142
+
143
+ ```css
144
+ .grid { perspective: 1000px; }
145
+ .card { transform-style: preserve-3d; }
146
+ ```
147
+
148
+ Keep it under ~15°. Gate on `(hover: hover)` — see
149
+ [interaction.md](../reference/interaction.md).
150
+
151
+ ## Reduced motion
152
+
153
+ Set the end state and keep the hover affordances as instant state changes:
154
+
155
+ ```ts
156
+ if (reduced) {
157
+ gsap.set("[data-card]", { autoAlpha: 1, y: 0, scale: 1, rotation: 0 });
158
+ return;
159
+ }
160
+ ```
161
+
162
+ For the stacking variant, drop the pin entirely — pinned scroll-scrubbing is
163
+ precisely the vestibular trigger. Let the cards flow normally.
164
+
165
+ ## Choosing
166
+
167
+ | Content | Pattern |
168
+ |---|---|
169
+ | A set that arrives together | deal in |
170
+ | A sequence the visitor walks through | stacking on scroll |
171
+ | A filterable collection | Flip reflow |
172
+ | A gallery | reveal + hover lift |
173
+
174
+ If the cards are a list of links, none of this. A list of links wants a fast
175
+ stagger and nothing else.
@@ -0,0 +1,140 @@
1
+ # Cinematic
2
+
3
+ "Make it feel cinematic" is a request about **pacing**, not about effects. The
4
+ techniques below are ordinary; what makes them cinematic is that one thing
5
+ happens at a time, each beat is given room, and nothing is rushed.
6
+
7
+ A worked example, with the code that matters:
8
+ [example/cinematic-intro.md](../example/cinematic-intro.md).
9
+
10
+ ## What the word means
11
+
12
+ | Cinematic | Not cinematic |
13
+ |---|---|
14
+ | one thing moving at a time | six things arriving together |
15
+ | 0.8–1.6s beats | 300ms beats |
16
+ | `power3.inOut`, `expo.out` | `back.out`, `elastic` |
17
+ | holds between beats | continuous motion |
18
+ | slow reveal of one subject | a grid of cards staggering in |
19
+ | dark, contrasty, generous space | busy layout |
20
+
21
+ The most common failure is treating it as "add more motion". Cinematic is
22
+ usually **less** motion, held longer.
23
+
24
+ ## Structure
25
+
26
+ Three acts, and the hold is not optional — it is where the weight comes from.
27
+
28
+ ```text
29
+ establish → the subject arrives alone, slowly
30
+ develop → supporting elements enter, each given its own beat
31
+ hold → everything settles; nothing moves for 300–600ms
32
+ release → the interface returns, or the scene hands over
33
+ ```
34
+
35
+ ## Site intro
36
+
37
+ ```ts
38
+ const tl = gsap.timeline({
39
+ defaults: { ease: "power3.out" },
40
+ onComplete: finish,
41
+ });
42
+
43
+ tl.addLabel("establish")
44
+ .fromTo("[data-mark-outline]", { drawSVG: "0%" },
45
+ { drawSVG: "100%", duration: 1.2, stagger: 0.14, ease: "power2.inOut" })
46
+ .to("[data-mark-fill]", { autoAlpha: 1, duration: 0.5 }, "-=0.5")
47
+ .to("[data-mark-outline]", { autoAlpha: 0, duration: 0.35 }, "<0.15")
48
+
49
+ .addLabel("develop")
50
+ .from("[data-word-line]", { yPercent: 110, duration: 0.9, stagger: 0.08 }, "develop")
51
+
52
+ .addLabel("hold")
53
+ .to({}, { duration: 0.5 })
54
+
55
+ .addLabel("release")
56
+ .to("[data-curtain]", { yPercent: -100, duration: 0.9, ease: "power4.inOut" });
57
+ ```
58
+
59
+ Labels rather than offsets: a cinematic sequence gets retimed many times, and
60
+ labels are what survive that.
61
+
62
+ ## Curtain lift
63
+
64
+ The release beat. A wipe reads more deliberate than a fade.
65
+
66
+ ```ts
67
+ .to(curtain, { yPercent: -100, duration: 0.9, ease: "power4.inOut" })
68
+ .from(content, { yPercent: 8, autoAlpha: 0, duration: 0.9 }, "<0.15");
69
+ ```
70
+
71
+ The content lagging slightly behind the curtain is what makes it feel revealed
72
+ rather than switched.
73
+
74
+ ## Letterbox
75
+
76
+ Two bars closing in, then opening. Cheap and unmistakably filmic — but it is a
77
+ strong gesture, so once per site.
78
+
79
+ ```ts
80
+ gsap.from("[data-bar]", { scaleY: 0, transformOrigin: "top", duration: 0.6 });
81
+ ```
82
+
83
+ ## Camera language
84
+
85
+ Real depth comes from layers moving at different rates. The subject barely
86
+ moves; the background does.
87
+
88
+ ```ts
89
+ tl.from("[data-bg]", { scale: 1.15, duration: 2.4, ease: "power2.out" })
90
+ .from("[data-mid]", { scale: 1.08, duration: 2.4, ease: "power2.out" }, "<")
91
+ .from("[data-subject]", { scale: 1.02, autoAlpha: 0, duration: 1.6 }, "<0.3");
92
+ ```
93
+
94
+ Same direction, different magnitudes. A slow push-in on the background under a
95
+ still subject is the single most effective cinematic device available in CSS.
96
+
97
+ ## Ceiling
98
+
99
+ An unskippable intro must have a hard time limit, and dismissal must be a
100
+ **race** rather than a sequence:
101
+
102
+ ```ts
103
+ let done = false;
104
+ const finish = () => { if (done) return; done = true; onDone(); };
105
+ const ceiling = window.setTimeout(finish, 4200);
106
+ return () => window.clearTimeout(ceiling);
107
+ ```
108
+
109
+ Keep the total under ~3s. Play it once per session, not once per route — an
110
+ intro on every navigation is an obstacle. Render the page underneath the whole
111
+ time so a JavaScript failure cannot trap the visitor behind a curtain.
112
+
113
+ ## Reduced motion
114
+
115
+ Cinematic motion is the most likely thing on a page to cause discomfort: large
116
+ travel, scaling, unrequested, unskippable. Give the composed final frame and a
117
+ shorter hold.
118
+
119
+ ```ts
120
+ if (reduced) {
121
+ gsap.set("[data-mark-outline]", { autoAlpha: 0 });
122
+ gsap.set(["[data-mark-fill]", "[data-word-line]"], { autoAlpha: 1, yPercent: 0 });
123
+ gsap.delayedCall(0.9, finish);
124
+ return;
125
+ }
126
+ ```
127
+
128
+ Note the second `gsap.set`: a `from` tween that never runs leaves its target at
129
+ the authored start — invisible. The reduced branch must set the end state, not
130
+ just skip the motion.
131
+
132
+ ## Tuning
133
+
134
+ | Feels | Change |
135
+ |---|---|
136
+ | rushed | lengthen the holds before lengthening the beats |
137
+ | sluggish | overlap more; keep the durations |
138
+ | cheap | `power3.inOut` instead of `power1`; remove any overshoot |
139
+ | busy | cut elements until one carries each beat |
140
+ | like a slideshow | overlap at 60–70%, and add the hold |
@@ -0,0 +1,184 @@
1
+ # Cursor
2
+
3
+ A custom pointer. High reward and high risk: done well it makes a site feel
4
+ authored, done badly it makes it unusable.
5
+
6
+ **Decide first whether to hide the native cursor.** Keeping it and adding a
7
+ follower is almost always the better trade — you get the character without
8
+ taking away the visitor's most reliable affordance.
9
+
10
+ ## Two layers, two speeds
11
+
12
+ One element tracking instantly, one lagging. That difference is the whole
13
+ effect; a single follower reads as a laggy cursor.
14
+
15
+ ```ts
16
+ "use client";
17
+
18
+ import { useGSAP } from "@gsap/react";
19
+ import gsap from "gsap";
20
+ import { useRef } from "react";
21
+
22
+ gsap.registerPlugin(useGSAP);
23
+
24
+ export function useCursor() {
25
+ const dotRef = useRef<HTMLDivElement>(null);
26
+ const ringRef = useRef<HTMLDivElement>(null);
27
+
28
+ useGSAP(() => {
29
+ const dot = dotRef.current;
30
+ const ring = ringRef.current;
31
+ if (!dot || !ring) return;
32
+
33
+ const mm = gsap.matchMedia();
34
+
35
+ /**
36
+ * A follower on a touch device is dead weight parked in a corner, and one
37
+ * that chases a pointer for someone who asked for reduced motion is the
38
+ * kind of unrequested movement the preference exists to stop.
39
+ */
40
+ mm.add(
41
+ "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)",
42
+ () => {
43
+ const to = (el: HTMLElement, axis: "x" | "y", duration: number) =>
44
+ gsap.quickTo(el, axis, { duration, ease: "power3" });
45
+
46
+ const dotX = to(dot, "x", 0.12);
47
+ const dotY = to(dot, "y", 0.12);
48
+ const ringX = to(ring, "x", 0.5);
49
+ const ringY = to(ring, "y", 0.5);
50
+
51
+ gsap.set([dot, ring], { xPercent: -50, yPercent: -50, autoAlpha: 0 });
52
+
53
+ let seen = false;
54
+ const onMove = (event: PointerEvent) => {
55
+ if (!seen) {
56
+ seen = true;
57
+ /** Revealed on first move, so it never paints in the corner. */
58
+ gsap.to([dot, ring], { autoAlpha: 1, duration: 0.3 });
59
+ }
60
+ dotX(event.clientX);
61
+ dotY(event.clientY);
62
+ ringX(event.clientX);
63
+ ringY(event.clientY);
64
+ };
65
+
66
+ /** Hidden when the pointer leaves the document entirely. */
67
+ const onOut = () => gsap.to([dot, ring], { autoAlpha: 0, duration: 0.2 });
68
+
69
+ window.addEventListener("pointermove", onMove);
70
+ document.addEventListener("pointerleave", onOut);
71
+
72
+ return () => {
73
+ window.removeEventListener("pointermove", onMove);
74
+ document.removeEventListener("pointerleave", onOut);
75
+ };
76
+ },
77
+ );
78
+
79
+ /* The context reverts `mm` itself; the listeners above are ours. */
80
+ });
81
+
82
+ return { dotRef, ringRef };
83
+ }
84
+ ```
85
+
86
+ `xPercent: -50, yPercent: -50` centres each layer on the pointer without knowing
87
+ its size.
88
+
89
+ ## Markup
90
+
91
+ ```tsx
92
+ <div ref={ringRef} aria-hidden="true"
93
+ className="pointer-events-none fixed start-0 top-0 z-200 size-9 rounded-full border" />
94
+ <div ref={dotRef} aria-hidden="true"
95
+ className="pointer-events-none fixed start-0 top-0 z-200 size-1.5 rounded-full" />
96
+ ```
97
+
98
+ `pointer-events: none` and `aria-hidden` on both, always. `fixed` positioning so
99
+ the coordinates are viewport coordinates — no scroll maths.
100
+
101
+ ## Reacting to what is under it
102
+
103
+ The character comes from the follower changing over different targets. Drive it
104
+ from a data attribute rather than a list of selectors:
105
+
106
+ ```tsx
107
+ <a data-cursor="expand">…</a>
108
+ <video data-cursor="play">…</video>
109
+ ```
110
+
111
+ ```ts
112
+ const onOver = (event: PointerEvent) => {
113
+ const hit = (event.target as Element).closest("[data-cursor]");
114
+ const mode = hit?.getAttribute("data-cursor");
115
+
116
+ gsap.to(ring, {
117
+ scale: mode === "expand" ? 2.4 : 1,
118
+ borderWidth: mode === "expand" ? 1 : 2,
119
+ duration: 0.4,
120
+ ease: "power3.out",
121
+ overwrite: "auto",
122
+ });
123
+ };
124
+
125
+ document.addEventListener("pointerover", onOver);
126
+ ```
127
+
128
+ `overwrite: "auto"` matters here — moving quickly across several targets
129
+ otherwise leaves the ring blended between two states.
130
+
131
+ ## Capture
132
+
133
+ The ring sticks to an element's centre while the pointer is over it. This is the
134
+ effect that makes navigation feel magnetic without moving the link itself:
135
+
136
+ ```ts
137
+ const rect = hit.getBoundingClientRect();
138
+ ringX(rect.left + rect.width / 2);
139
+ ringY(rect.top + rect.height / 2);
140
+ ```
141
+
142
+ Combine with [magnetic.md](magnetic.md) carefully — both moving at once reads as
143
+ instability. Pick one.
144
+
145
+ ## Hiding the native cursor
146
+
147
+ If you must:
148
+
149
+ ```css
150
+ @media (hover: hover) and (pointer: fine) {
151
+ body { cursor: none; }
152
+ }
153
+ ```
154
+
155
+ Then the replacement must be at least as legible over every surface — dark,
156
+ light, image, video — and must never apply over text inputs or textareas, where
157
+ the I-beam carries real information:
158
+
159
+ ```css
160
+ input, textarea, [contenteditable] { cursor: auto; }
161
+ ```
162
+
163
+ Restore the cursor on any surface where your follower might not render: a
164
+ `<canvas>`, an `<iframe>`, a native `<select>` popup.
165
+
166
+ ## Accessibility
167
+
168
+ The follower is decoration. Nothing about it may carry information — a "click
169
+ me" that only exists as a cursor state is invisible to a keyboard, to touch, and
170
+ to a screen reader.
171
+
172
+ The `(hover: hover)` and reduced-motion gates in the `matchMedia` query are not
173
+ optional.
174
+
175
+ ## Failures
176
+
177
+ | Symptom | Cause |
178
+ |---|---|
179
+ | Sits in the corner on mobile | missing `(hover: hover)` gate |
180
+ | Flashes at 0,0 on load | not hidden until the first move |
181
+ | Blocks clicks | missing `pointer-events: none` |
182
+ | Blends between hover states | missing `overwrite: "auto"` |
183
+ | Offset by the scroll | `absolute` instead of `fixed` |
184
+ | Survives navigation | listeners not removed |
@@ -0,0 +1,172 @@
1
+ # Loader
2
+
3
+ A loader's job is to say "this is working" and, if possible, "for about this
4
+ long". Everything else is decoration.
5
+
6
+ Two rules that outrank craft: **it must never be able to hang**, and **it must
7
+ show something under reduced motion** — a loader with nothing moving reads as a
8
+ page that has crashed.
9
+
10
+ A worked example, with the code that matters: [example/svg-loader.md](../example/svg-loader.md).
11
+
12
+ ## Travelling dash
13
+
14
+ The one to reach for. No DOM churn, no layout, one property.
15
+
16
+ ```ts
17
+ gsap.timeline({ repeat: -1 })
18
+ .fromTo("#ring", { drawSVG: "0% 0%" },
19
+ { drawSVG: "0% 75%", duration: 0.9, ease: "power2.inOut" })
20
+ .to("#ring", { drawSVG: "100% 100%", duration: 0.9, ease: "power2.inOut" });
21
+
22
+ gsap.to("#ring", {
23
+ rotation: 360, transformOrigin: "50% 50%",
24
+ repeat: -1, duration: 1.8, ease: "none",
25
+ });
26
+ ```
27
+
28
+ Two tweens because growing and shrinking have different origins — that
29
+ difference is what reads as a pulse rather than a slide. The rotation runs
30
+ separately so the dash never appears to stall at the seam.
31
+
32
+ Needs a stroke. See [svg.md](../reference/svg.md).
33
+
34
+ ## Morph cycle
35
+
36
+ ```ts
37
+ const tl = gsap.timeline({ repeat: -1 });
38
+
39
+ for (const shape of ["#b", "#c", "#a"]) {
40
+ tl.to("#morph", {
41
+ morphSVG: { shape, type: "rotational" },
42
+ duration: 0.7,
43
+ ease: "power2.inOut",
44
+ }).to({}, { duration: 0.3 }); // hold, so each shape is legible
45
+ }
46
+ ```
47
+
48
+ The empty tween is a hold that is visible in the structure. Keep node counts
49
+ within 3× or the morph smears.
50
+
51
+ ## Orbit
52
+
53
+ ```ts
54
+ gsap.to("#dot", {
55
+ motionPath: { path: "#track", align: "#track",
56
+ alignOrigin: [0.5, 0.5], autoRotate: true },
57
+ duration: 2.4, repeat: -1, ease: "none",
58
+ });
59
+ ```
60
+
61
+ `ease: "none"` — anything else stutters at the loop seam. Several orbiters:
62
+ `stagger: { each: duration / count, repeat: -1 }`.
63
+
64
+ **The viewBox must contain the orbit, not just the artwork.** An orbit around a
65
+ wide lockup is taller than it, and the dot is clipped for part of every lap.
66
+
67
+ ## Logo that draws itself
68
+
69
+ A filled logo has no stroke for DrawSVG to animate. Two layers of the same
70
+ geometry: a stroked copy draws, the filled version fades in under it, the
71
+ outline fades out.
72
+
73
+ ```ts
74
+ tl.fromTo("[data-outline]", { drawSVG: "0%" },
75
+ { drawSVG: "100%", duration: 1.2, stagger: 0.12, ease: "power2.inOut" })
76
+ .to("[data-fill]", { autoAlpha: 1, duration: 0.4 }, "-=0.5")
77
+ .to("[data-outline]", { autoAlpha: 0, duration: 0.3 }, "<0.15");
78
+ ```
79
+
80
+ A useful side effect: with JavaScript unavailable the stroked layer is what
81
+ renders, so the fallback is the mark in outline rather than an empty box.
82
+
83
+ ## Determinate progress
84
+
85
+ When there is a real number, drive the timeline instead of looping it.
86
+
87
+ ```ts
88
+ const tl = gsap.timeline({ paused: true });
89
+ tl.fromTo("#ring", { drawSVG: "0%" }, { drawSVG: "100%", ease: "none" });
90
+
91
+ gsap.to(tl, { progress, duration: 0.4, ease: "power2.out", overwrite: true });
92
+ ```
93
+
94
+ Tween the progress rather than setting it — that is what smooths a value
95
+ arriving as 0 → 40 → 100. `overwrite: true` stops two updates fighting.
96
+
97
+ Never let a determinate bar sit at 100% while work continues. Cap the animated
98
+ value at ~90% until the work actually completes.
99
+
100
+ ## Dismissal must be a race
101
+
102
+ This is the part that breaks in production. A loader waiting on a signal can
103
+ wait forever — a failed request, a missed event, a `load` that never fires
104
+ because one image is stuck. The result is not a slow page; it is a page nobody
105
+ can reach.
106
+
107
+ ```ts
108
+ let done = false;
109
+ const finish = () => { if (done) return; done = true; onDone(); };
110
+
111
+ const ceiling = window.setTimeout(finish, MAX_VISIBLE_MS);
112
+ tl.eventCallback("onComplete", finish);
113
+
114
+ return () => window.clearTimeout(ceiling);
115
+ ```
116
+
117
+ A timeline containing an infinitely repeating child **never completes**, so its
118
+ `onComplete` never fires. Give the timeline a finite beat of its own to end on,
119
+ or drive the handover from elsewhere.
120
+
121
+ Render the real page underneath the whole time, so a visitor whose JavaScript
122
+ fails gets the site rather than a permanent curtain.
123
+
124
+ ## Exit
125
+
126
+ A loader that vanishes abruptly makes the page feel like it broke.
127
+
128
+ ```ts
129
+ const exit = gsap.timeline({ paused: true })
130
+ .to("#ring", { drawSVG: "100% 100%", duration: 0.4, ease: "power2.in" })
131
+ .to(root, { autoAlpha: 0, duration: 0.3 }, "-=0.1");
132
+ ```
133
+
134
+ ## Accessibility
135
+
136
+ - `role="status"` and an accessible name on the container. It announces
137
+ politely without stealing focus.
138
+ - The artwork inside is `aria-hidden`. Never put an `sr-only` label inside an
139
+ `aria-hidden` container — it is never read.
140
+ - Reduced motion: the artwork arrives already drawn, with a slow opacity pulse.
141
+ Keep *something* moving.
142
+
143
+ ```ts
144
+ if (reduced) {
145
+ gsap.set("[data-outline]", { drawSVG: "100%" });
146
+ gsap.to("[data-pulse]", { autoAlpha: 0.35, duration: 1.1,
147
+ repeat: -1, yoyo: true, ease: "sine.inOut" });
148
+ return;
149
+ }
150
+ ```
151
+
152
+ ## Duration
153
+
154
+ | Wait | Show |
155
+ |---|---|
156
+ | under 300ms | nothing — a flashed loader is worse than none |
157
+ | 300ms–1s | a simple spinner |
158
+ | 1–5s | a loader with character; a brand moment is affordable |
159
+ | over 5s | determinate progress, or explain what is happening |
160
+
161
+ Delay the loader's own appearance by ~300ms so fast responses never flash one.
162
+
163
+ ## Failures
164
+
165
+ | Symptom | Cause |
166
+ |---|---|
167
+ | Nothing moves, no error | `drawSVG` on a shape with no stroke |
168
+ | Curtain never lifts | dismissal waits on a signal with no ceiling |
169
+ | `onComplete` never fires | an infinitely repeating child |
170
+ | Stutters once per loop | an ease other than `none` on a repeat |
171
+ | Dot clipped mid-orbit | viewBox sized to the artwork, not the orbit |
172
+ | Two loaders move as one | shared hardcoded ids in plugin config |