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